diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 2f8ad9cf0a18b..2d77538b09572 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -3131,6 +3131,22 @@ } } }, + "apm-custom-dashboards": { + "properties": { + "dashboardSavedObjectId": { + "type": "keyword" + }, + "kuery": { + "type": "text" + }, + "serviceEnvironmentFilterEnabled": { + "type": "boolean" + }, + "serviceNameFilterEnabled": { + "type": "boolean" + } + } + }, "enterprise_search_telemetry": { "dynamic": false, "properties": {} diff --git a/packages/kbn-shared-svg/index.ts b/packages/kbn-shared-svg/index.ts index 61ffc0f763cba..215431706ab94 100644 --- a/packages/kbn-shared-svg/index.ts +++ b/packages/kbn-shared-svg/index.ts @@ -8,5 +8,7 @@ import noResultsIllustrationDark from './src/assets/no_results_dark.svg'; import noResultsIllustrationLight from './src/assets/no_results_light.svg'; +import dashboardsLight from './src/assets/dashboards_light.svg'; +import dashboardsDark from './src/assets/dashboards_dark.svg'; -export { noResultsIllustrationDark, noResultsIllustrationLight }; +export { noResultsIllustrationDark, noResultsIllustrationLight, dashboardsLight, dashboardsDark }; diff --git a/packages/kbn-shared-svg/src/assets/dashboards_dark.svg b/packages/kbn-shared-svg/src/assets/dashboards_dark.svg new file mode 100644 index 0000000000000..6499131ce6b5a --- /dev/null +++ b/packages/kbn-shared-svg/src/assets/dashboards_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kbn-shared-svg/src/assets/dashboards_light.svg b/packages/kbn-shared-svg/src/assets/dashboards_light.svg new file mode 100644 index 0000000000000..4ca82bfe3ff98 --- /dev/null +++ b/packages/kbn-shared-svg/src/assets/dashboards_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 423008ee1eece..b5a1591e7a5bd 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -59,6 +59,7 @@ describe('checking migration metadata changes on all registered SO types', () => "action_task_params": "96e27e7f4e8273ffcd87060221e2b75e81912dd5", "alert": "dc710bc17dfc98a9a703d388569abccce5f8bf07", "api_key_pending_invalidation": "1399e87ca37b3d3a65d269c924eda70726cfe886", + "apm-custom-dashboards": "b67128f78160c288bd7efe25b2da6e2afd5e82fc", "apm-indices": "8a2d68d415a4b542b26b0d292034a28ffac6fed4", "apm-server-schema": "58a8c6468edae3d1dc520f0134f59cf3f4fd7eff", "apm-service-group": "66dfc1ddd40bad8f693c873bf6002ca30079a4ae", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index efb439e058cc2..2cef3801868bd 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -15,6 +15,7 @@ const previouslyRegisteredTypes = [ 'action_task_params', 'alert', 'api_key_pending_invalidation', + 'apm-custom-dashboards', 'apm-indices', 'apm-server-schema', 'apm-service-group', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 3c41eafb6102c..c39ceaf30da69 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -181,6 +181,7 @@ describe('split .kibana index into multiple system indices', () => { "action_task_params", "alert", "api_key_pending_invalidation", + "apm-custom-dashboards", "apm-indices", "apm-server-schema", "apm-service-group", diff --git a/x-pack/plugins/apm/common/custom_dashboards.ts b/x-pack/plugins/apm/common/custom_dashboards.ts new file mode 100644 index 0000000000000..7e289d970b2e6 --- /dev/null +++ b/x-pack/plugins/apm/common/custom_dashboards.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE = 'apm-custom-dashboards'; + +export interface ApmCustomDashboard { + dashboardSavedObjectId: string; + serviceNameFilterEnabled: boolean; + serviceEnvironmentFilterEnabled: boolean; + kuery?: string; +} + +export interface SavedApmCustomDashboard extends ApmCustomDashboard { + id: string; + updatedAt: number; +} diff --git a/x-pack/plugins/apm/public/components/app/metrics/static_dashboard/index.tsx b/x-pack/plugins/apm/public/components/app/metrics/static_dashboard/index.tsx index 789239ffa1d68..50bda742eb377 100644 --- a/x-pack/plugins/apm/public/components/app/metrics/static_dashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/metrics/static_dashboard/index.tsx @@ -109,7 +109,7 @@ async function getCreationOptions( } } -function getFilters( +export function getFilters( serviceName: string, environment: string, dataView: DataView @@ -139,7 +139,7 @@ function getFilters( } else { const environmentFilter = buildPhraseFilter( environmentField, - serviceName, + environment, dataView ); filters.push(environmentFilter); diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/edit_dashboard.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/edit_dashboard.tsx new file mode 100644 index 0000000000000..e3a6619b446d6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/edit_dashboard.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { SaveDashboardModal } from './save_dashboard_modal'; +import { MergedServiceDashboard } from '..'; + +export function EditDashboard({ + onRefresh, + currentDashboard, +}: { + onRefresh: () => void; + currentDashboard: MergedServiceDashboard; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + return ( + <> + setIsModalVisible(!isModalVisible)} + > + {i18n.translate('xpack.apm.serviceDashboards.editEmptyButtonLabel', { + defaultMessage: 'Edit dashboard link', + })} + + + {isModalVisible && ( + setIsModalVisible(!isModalVisible)} + onRefresh={onRefresh} + currentDashboard={currentDashboard} + /> + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/goto_dashboard.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/goto_dashboard.tsx new file mode 100644 index 0000000000000..f196077c41a4d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/goto_dashboard.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ApmPluginStartDeps } from '../../../../plugin'; +import { SavedApmCustomDashboard } from '../../../../../common/custom_dashboards'; + +export function GotoDashboard({ + currentDashboard, +}: { + currentDashboard: SavedApmCustomDashboard; +}) { + const { + services: { + dashboard: { locator: dashboardLocator }, + }, + } = useKibana(); + + const url = dashboardLocator?.getRedirectUrl({ + dashboardId: currentDashboard?.dashboardSavedObjectId, + }); + return ( + + {i18n.translate('xpack.apm.serviceDashboards.contextMenu.goToDashboard', { + defaultMessage: 'Go to dashboard', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/index.ts b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/index.ts new file mode 100644 index 0000000000000..c37616318239f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LinkDashboard } from './link_dashboard'; +import { GotoDashboard } from './goto_dashboard'; +import { EditDashboard } from './edit_dashboard'; + +export { LinkDashboard, GotoDashboard, EditDashboard }; diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/link_dashboard.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/link_dashboard.tsx new file mode 100644 index 0000000000000..7b652c21039d8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/link_dashboard.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { MergedServiceDashboard } from '..'; +import { SaveDashboardModal } from './save_dashboard_modal'; + +export function LinkDashboard({ + onRefresh, + emptyButton = false, + serviceDashboards, +}: { + onRefresh: () => void; + emptyButton?: boolean; + serviceDashboards?: MergedServiceDashboard[]; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + + return ( + <> + {emptyButton ? ( + setIsModalVisible(true)} + > + {i18n.translate('xpack.apm.serviceDashboards.linkEmptyButtonLabel', { + defaultMessage: 'Link new dashboard', + })} + + ) : ( + setIsModalVisible(true)} + > + {i18n.translate('xpack.apm.serviceDashboards.linkButtonLabel', { + defaultMessage: 'Link dashboard', + })} + + )} + + {isModalVisible && ( + setIsModalVisible(false)} + onRefresh={onRefresh} + serviceDashboards={serviceDashboards} + /> + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx new file mode 100644 index 0000000000000..81dc0ba157a01 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx @@ -0,0 +1,276 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSwitch, + EuiModalBody, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiToolTip, + EuiIcon, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; +import { callApmApi } from '../../../../services/rest/create_call_apm_api'; +import { useDashboardFetcher } from '../../../../hooks/use_dashboards_fetcher'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { SERVICE_NAME } from '../../../../../common/es_fields/apm'; +import { MergedServiceDashboard } from '..'; + +interface Props { + onClose: () => void; + onRefresh: () => void; + currentDashboard?: MergedServiceDashboard; + serviceDashboards?: MergedServiceDashboard[]; +} + +export function SaveDashboardModal({ + onClose, + onRefresh, + currentDashboard, + serviceDashboards, +}: Props) { + const { + core: { notifications }, + } = useApmPluginContext(); + const { data: allAvailableDashboards, status } = useDashboardFetcher(); + + let defaultOption: EuiComboBoxOptionOption | undefined; + + const [serviceFiltersEnabled, setserviceFiltersEnabled] = useState( + (currentDashboard?.serviceEnvironmentFilterEnabled && + currentDashboard?.serviceNameFilterEnabled) ?? + true + ); + + if (currentDashboard) { + const { title, dashboardSavedObjectId } = currentDashboard; + defaultOption = { label: title, value: dashboardSavedObjectId }; + } + + const [selectedDashboard, setSelectedDashboard] = useState( + defaultOption ? [defaultOption] : [] + ); + + const isEditMode = !!currentDashboard?.id; + + const { + path: { serviceName }, + } = useApmParams('/services/{serviceName}/dashboards'); + + const reloadCustomDashboards = useCallback(() => { + onRefresh(); + }, [onRefresh]); + + const options = allAvailableDashboards?.map( + (dashboardItem: DashboardItem) => ({ + label: dashboardItem.attributes.title, + value: dashboardItem.id, + disabled: + serviceDashboards?.some( + ({ dashboardSavedObjectId }) => + dashboardItem.id === dashboardSavedObjectId + ) ?? false, + }) + ); + const onSave = useCallback( + async function () { + const [newDashboard] = selectedDashboard; + try { + if (newDashboard.value) { + await callApmApi('POST /internal/apm/custom-dashboard', { + params: { + query: { customDashboardId: currentDashboard?.id }, + body: { + dashboardSavedObjectId: newDashboard.value, + serviceEnvironmentFilterEnabled: serviceFiltersEnabled, + serviceNameFilterEnabled: serviceFiltersEnabled, + kuery: `${SERVICE_NAME}: ${serviceName}`, + }, + }, + signal: null, + }); + + notifications.toasts.addSuccess( + isEditMode + ? getEditSuccessToastLabels(newDashboard.label) + : getLinkSuccessToastLabels(newDashboard.label) + ); + reloadCustomDashboards(); + } + } catch (error) { + console.error(error); + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.serviceDashboards.addFailure.toast.title', + { + defaultMessage: 'Error while adding "{dashboardName}" dashboard', + values: { dashboardName: newDashboard.label }, + } + ), + text: error.body.message, + }); + } + onClose(); + }, + [ + selectedDashboard, + notifications.toasts, + serviceFiltersEnabled, + onClose, + reloadCustomDashboards, + isEditMode, + serviceName, + currentDashboard, + ] + ); + + return ( + + + + {isEditMode + ? i18n.translate( + 'xpack.apm.serviceDashboards.selectDashboard.modalTitle.edit', + { + defaultMessage: 'Edit dashboard', + } + ) + : i18n.translate( + 'xpack.apm.serviceDashboards.selectDashboard.modalTitle.link', + { + defaultMessage: 'Select dashboard', + } + )} + + + + + + setSelectedDashboard(newSelection)} + isClearable={true} + /> + + + {i18n.translate( + 'xpack.apm.dashboard.addDashboard.useContextFilterLabel', + { + defaultMessage: 'Filter by service and environment', + } + )}{' '} + + + +

+ } + onChange={() => setserviceFiltersEnabled(!serviceFiltersEnabled)} + checked={serviceFiltersEnabled} + /> +
+
+ + + + {i18n.translate( + 'xpack.apm.serviceDashboards.selectDashboard.cancel', + { + defaultMessage: 'Cancel', + } + )} + + + {isEditMode + ? i18n.translate( + 'xpack.apm.serviceDashboards.selectDashboard.edit', + { + defaultMessage: 'Save', + } + ) + : i18n.translate( + 'xpack.apm.serviceDashboards.selectDashboard.add', + { + defaultMessage: 'Link dashboard', + } + )} + + +
+ ); +} + +function getLinkSuccessToastLabels(dashboardName: string) { + return { + title: i18n.translate( + 'xpack.apm.serviceDashboards.linkSuccess.toast.title', + { + defaultMessage: 'Added "{dashboardName}" dashboard', + values: { dashboardName }, + } + ), + text: i18n.translate('xpack.apm.serviceDashboards.linkSuccess.toast.text', { + defaultMessage: + 'Your dashboard is now visible in the service overview page.', + }), + }; +} + +function getEditSuccessToastLabels(dashboardName: string) { + return { + title: i18n.translate( + 'xpack.apm.serviceDashboards.editSuccess.toast.title', + { + defaultMessage: 'Edited "{dashboardName}" dashboard', + values: { dashboardName }, + } + ), + text: i18n.translate('xpack.apm.serviceDashboards.editSuccess.toast.text', { + defaultMessage: 'Your dashboard link have been updated', + }), + }; +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/actions/unlink_dashboard.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/unlink_dashboard.tsx new file mode 100644 index 0000000000000..b0dbda84bb6cf --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/actions/unlink_dashboard.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useState } from 'react'; +import { MergedServiceDashboard } from '..'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { callApmApi } from '../../../../services/rest/create_call_apm_api'; + +export function UnlinkDashboard({ + currentDashboard, + onRefresh, +}: { + currentDashboard: MergedServiceDashboard; + onRefresh: () => void; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + const { + core: { notifications }, + } = useApmPluginContext(); + + const onConfirm = useCallback( + async function () { + try { + await callApmApi('DELETE /internal/apm/custom-dashboard', { + params: { query: { customDashboardId: currentDashboard.id } }, + signal: null, + }); + + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.serviceDashboards.unlinkSuccess.toast.title', + { + defaultMessage: 'Unlinked "{dashboardName}" dashboard', + values: { dashboardName: currentDashboard?.title }, + } + ), + }); + onRefresh(); + } catch (error) { + console.error(error); + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.serviceDashboards.unlinkFailure.toast.title', + { + defaultMessage: + 'Error while unlinking "{dashboardName}" dashboard', + values: { dashboardName: currentDashboard?.title }, + } + ), + text: error.body.message, + }); + } + setIsModalVisible(!isModalVisible); + }, + [ + currentDashboard, + notifications.toasts, + setIsModalVisible, + onRefresh, + isModalVisible, + ] + ); + return ( + <> + setIsModalVisible(true)} + > + {i18n.translate('xpack.apm.serviceDashboards.unlinkEmptyButtonLabel', { + defaultMessage: 'Unlink dashboard', + })} + + {isModalVisible && ( + setIsModalVisible(false)} + onConfirm={onConfirm} + confirmButtonText={i18n.translate( + 'xpack.apm.serviceDashboards.unlinkEmptyButtonLabel.confirm.button', + { + defaultMessage: 'Unlink dashboard', + } + )} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

+ {i18n.translate( + 'xpack.apm.serviceDashboards.unlinkEmptyButtonLabel.confirm.body', + { + defaultMessage: + 'You are about to unlink the dashboard from the service context', + } + )} +

+
+ )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/context_menu.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/context_menu.tsx new file mode 100644 index 0000000000000..2eb48b7f66848 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/context_menu.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiButtonIcon, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiPopover, +} from '@elastic/eui'; + +interface Props { + items: React.ReactNode[]; +} + +export function ContextMenu({ items }: Props) { + const [isPopoverOpen, setPopover] = useState(false); + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + ( + {item} + ))} + /> + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/dashboard_selector.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/dashboard_selector.tsx new file mode 100644 index 0000000000000..115b97ad41cc8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/dashboard_selector.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiComboBox } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { MergedServiceDashboard } from '.'; +import { fromQuery, toQuery } from '../../shared/links/url_helpers'; + +interface Props { + serviceDashboards: MergedServiceDashboard[]; + currentDashboard?: MergedServiceDashboard; + handleOnChange: (selectedId?: string) => void; +} + +export function DashboardSelector({ + serviceDashboards, + currentDashboard, + handleOnChange, +}: Props) { + const history = useHistory(); + + useEffect( + () => + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(location.search), + dashboardId: currentDashboard?.id, + }), + }), + // It should only update when loaded + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + function onChange(newDashboardId?: string) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(location.search), + dashboardId: newDashboardId, + }), + }); + handleOnChange(newDashboardId); + } + return ( + { + return { + label: title, + value: dashboardSavedObjectId, + }; + })} + selectedOptions={ + currentDashboard + ? [ + { + value: currentDashboard?.dashboardSavedObjectId, + label: currentDashboard?.title, + }, + ] + : [] + } + onChange={([newItem]) => onChange(newItem.value)} + isClearable={false} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/empty_dashboards.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/empty_dashboards.tsx new file mode 100644 index 0000000000000..843a2c47b2649 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/empty_dashboards.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiEmptyPrompt, EuiImage } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { dashboardsDark, dashboardsLight } from '@kbn/shared-svg'; +import { useTheme } from '../../../hooks/use_theme'; + +interface Props { + actions: React.ReactNode; +} + +export function EmptyDashboards({ actions }: Props) { + const theme = useTheme(); + + return ( + <> + + } + title={ +

+ {i18n.translate('xpack.apm.serviceDashboards.emptyTitle', { + defaultMessage: + 'The best way to understand your data is to visualize it.', + })} +

+ } + layout="horizontal" + color="plain" + body={ + <> +
    +
  • + {i18n.translate('xpack.apm.serviceDashboards.emptyBody.first', { + defaultMessage: 'bring clarity to your data', + })} +
  • +
  • + {i18n.translate( + 'xpack.apm.serviceDashboards.emptyBody.second', + { + defaultMessage: 'tell a story about your data', + } + )} +
  • +
  • + {i18n.translate('xpack.apm.serviceDashboards.emptyBody', { + defaultMessage: + 'focus on only the data that’s important to you', + })} +
  • +
+

+ {i18n.translate( + 'xpack.apm.serviceDashboards.emptyBody.getStarted', + { + defaultMessage: 'To get started, add your dashaboard', + } + )} +

+ + } + actions={actions} + /> + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_dashboards/index.tsx b/x-pack/plugins/apm/public/components/app/service_dashboards/index.tsx new file mode 100644 index 0000000000000..f5df58b95cc41 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dashboards/index.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSpacer, + EuiEmptyPrompt, + EuiLoadingLogo, +} from '@elastic/eui'; + +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { + AwaitingDashboardAPI, + DashboardCreationOptions, + DashboardRenderer, +} from '@kbn/dashboard-plugin/public'; +import { EmptyDashboards } from './empty_dashboards'; +import { GotoDashboard, LinkDashboard } from './actions'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { SavedApmCustomDashboard } from '../../../../common/custom_dashboards'; +import { ContextMenu } from './context_menu'; +import { UnlinkDashboard } from './actions/unlink_dashboard'; +import { EditDashboard } from './actions/edit_dashboard'; +import { DashboardSelector } from './dashboard_selector'; +import { useApmDataView } from '../../../hooks/use_apm_data_view'; +import { getFilters } from '../metrics/static_dashboard'; +import { useDashboardFetcher } from '../../../hooks/use_dashboards_fetcher'; +import { useTimeRange } from '../../../hooks/use_time_range'; + +export interface MergedServiceDashboard extends SavedApmCustomDashboard { + title: string; +} + +export function ServiceDashboards() { + const { + path: { serviceName }, + query: { environment, kuery, rangeFrom, rangeTo, dashboardId }, + } = useApmParams('/services/{serviceName}/dashboards'); + const [dashboard, setDashboard] = useState(); + const [serviceDashboards, setServiceDashboards] = useState< + MergedServiceDashboard[] + >([]); + const [currentDashboard, setCurrentDashboard] = + useState(); + const { data: allAvailableDashboards } = useDashboardFetcher(); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { dataView } = useApmDataView(); + + const { data, status, refetch } = useFetcher( + (callApmApi) => { + if (serviceName) { + return callApmApi( + `GET /internal/apm/services/{serviceName}/dashboards`, + { + isCachable: false, + params: { + path: { serviceName }, + query: { start, end }, + }, + } + ); + } + }, + [serviceName, start, end] + ); + + useEffect(() => { + const filteredServiceDashbords = (data?.serviceDashboards ?? []).reduce( + ( + result: MergedServiceDashboard[], + serviceDashboard: SavedApmCustomDashboard + ) => { + const matchedDashboard = allAvailableDashboards.find( + ({ id }) => id === serviceDashboard.dashboardSavedObjectId + ); + if (matchedDashboard) { + result.push({ + title: matchedDashboard.attributes.title, + ...serviceDashboard, + }); + } + return result; + }, + [] + ); + + setServiceDashboards(filteredServiceDashbords); + + const preselectedDashboard = + filteredServiceDashbords.find( + ({ dashboardSavedObjectId }) => dashboardSavedObjectId === dashboardId + ) ?? filteredServiceDashbords[0]; + + // preselect dashboard + setCurrentDashboard(preselectedDashboard); + }, [allAvailableDashboards, data?.serviceDashboards, dashboardId]); + + const getCreationOptions = + useCallback((): Promise => { + const getInitialInput = () => ({ + viewMode: ViewMode.VIEW, + timeRange: { from: rangeFrom, to: rangeTo }, + }); + return Promise.resolve({ getInitialInput }); + }, [rangeFrom, rangeTo]); + + useEffect(() => { + if (!dashboard) return; + + dashboard.updateInput({ + filters: + dataView && + currentDashboard?.serviceEnvironmentFilterEnabled && + currentDashboard?.serviceNameFilterEnabled + ? getFilters(serviceName, environment, dataView) + : [], + timeRange: { from: rangeFrom, to: rangeTo }, + query: { query: kuery, language: 'kuery' }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + dataView, + serviceName, + environment, + kuery, + dashboard, + rangeFrom, + rangeTo, + ]); + + const handleOnChange = (selectedId?: string) => { + setCurrentDashboard( + serviceDashboards?.find( + ({ dashboardSavedObjectId }) => dashboardSavedObjectId === selectedId + ) + ); + }; + + return ( + + {status === FETCH_STATUS.LOADING ? ( + } + title={ +

+ {i18n.translate( + 'xpack.apm.serviceDashboards.loadingServiceDashboards', + { + defaultMessage: 'Loading service dashboard', + } + )} +

+ } + /> + ) : status === FETCH_STATUS.SUCCESS && serviceDashboards?.length > 0 ? ( + <> + + + +

{currentDashboard?.title}

+
+
+ + + + + + {currentDashboard && ( + + , + , + , + , + ]} + /> + + )} +
+ + + {currentDashboard && ( + + )} + + + ) : ( + } /> + )} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 5817b96e2b360..56deaaa2e6d6e 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -39,6 +39,7 @@ import { ApmServiceWrapper } from './apm_service_wrapper'; import { RedirectToDefaultServiceRouteView } from './redirect_to_default_service_route_view'; import { ProfilingOverview } from '../../app/profiling_overview'; import { SearchBar } from '../../shared/search_bar/search_bar'; +import { ServiceDashboards } from '../../app/service_dashboards'; function page({ title, @@ -376,6 +377,20 @@ export const serviceDetailRoute = { }, }), }, + '/services/{serviceName}/dashboards': { + ...page({ + tab: 'dashboards', + title: i18n.translate('xpack.apm.views.dashboard.title', { + defaultMessage: 'Dashboards', + }), + element: , + }), + params: t.partial({ + query: t.partial({ + dashboardId: t.string, + }), + }), + }, '/services/{serviceName}/': { element: , }, diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index 2e97a0e6156c5..b5261eb55826f 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -63,7 +63,8 @@ type Tab = NonNullable[0] & { | 'service-map' | 'logs' | 'alerts' - | 'profiling'; + | 'profiling' + | 'dashboards'; hidden?: boolean; }; @@ -417,6 +418,17 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { ), }, + { + key: 'dashboards', + href: router.link('/services/{serviceName}/dashboards', { + path: { serviceName }, + query, + }), + label: i18n.translate('xpack.apm.home.dashboardsTabLabel', { + defaultMessage: 'Dashboards', + }), + append: , + }, ]; return tabs diff --git a/x-pack/plugins/apm/public/hooks/use_dashboards_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_dashboards_fetcher.ts new file mode 100644 index 0000000000000..c463d07276a3a --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_dashboards_fetcher.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { SearchDashboardsResponse } from '@kbn/dashboard-plugin/public/services/dashboard_content_management/lib/find_dashboards'; +import { ApmPluginStartDeps } from '../plugin'; +import { FETCH_STATUS } from './use_fetcher'; + +export interface SearchDashboardsResult { + data: SearchDashboardsResponse['hits']; + status: FETCH_STATUS; +} + +export function useDashboardFetcher(query?: string): SearchDashboardsResult { + const { + services: { dashboard }, + } = useKibana(); + + const [result, setResult] = useState({ + data: [], + status: FETCH_STATUS.NOT_INITIATED, + }); + + useEffect(() => { + const getDashboards = async () => { + setResult({ + data: [], + status: FETCH_STATUS.LOADING, + }); + try { + const findDashboardsService = await dashboard?.findDashboardsService(); + const data = await findDashboardsService.search({ + search: query ?? '', + size: 1000, + }); + + setResult({ + data: data.hits, + status: FETCH_STATUS.SUCCESS, + }); + } catch { + setResult({ + data: [], + status: FETCH_STATUS.FAILURE, + }); + } + }; + getDashboards(); + }, [dashboard, query]); + return result; +} diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 800fb6bf123cd..f9206b8aaa782 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -69,6 +69,7 @@ import type { import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { from } from 'rxjs'; import { map } from 'rxjs/operators'; import type { ConfigSchema } from '.'; @@ -84,7 +85,6 @@ import { featureCatalogueEntry } from './feature_catalogue_entry'; import { APMServiceDetailLocator } from './locator/service_detail_locator'; import { ITelemetryClient, TelemetryService } from './services/telemetry'; export type ApmPluginSetup = ReturnType; - export type ApmPluginStart = void; export interface ApmPluginSetupDeps { @@ -136,6 +136,7 @@ export interface ApmPluginStartDeps { uiActions: UiActionsStart; profiling?: ProfilingPluginStart; observabilityAIAssistant: ObservabilityAIAssistantPluginStart; + dashboard: DashboardStart; } const servicesTitle = i18n.translate('xpack.apm.navigation.servicesTitle', { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 96e47f0edad19..525b2c5e2cbc5 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -33,6 +33,7 @@ import { apmTelemetry, apmServerSettings, apmServiceGroups, + apmCustomDashboards, } from './saved_objects'; import { APMPluginSetup, @@ -77,6 +78,7 @@ export class APMPlugin core.savedObjects.registerType(apmTelemetry); core.savedObjects.registerType(apmServerSettings); core.savedObjects.registerType(apmServiceGroups); + core.savedObjects.registerType(apmCustomDashboards); const currentConfig = this.initContext.config.get(); this.currentConfig = currentConfig; diff --git a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts index 4186523029c99..7c555366c9e68 100644 --- a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts @@ -46,6 +46,7 @@ import { traceRouteRepository } from '../traces/route'; import { transactionRouteRepository } from '../transactions/route'; import { assistantRouteRepository } from '../assistant_functions/route'; import { profilingRouteRepository } from '../profiling/route'; +import { serviceDashboardsRouteRepository } from '../custom_dashboards/route'; function getTypedGlobalApmServerRouteRepository() { const repository = { @@ -85,6 +86,7 @@ function getTypedGlobalApmServerRouteRepository() { ...diagnosticsRepository, ...assistantRouteRepository, ...profilingRouteRepository, + ...serviceDashboardsRouteRepository, }; return repository; diff --git a/x-pack/plugins/apm/server/routes/custom_dashboards/get_custom_dashboards.ts b/x-pack/plugins/apm/server/routes/custom_dashboards/get_custom_dashboards.ts new file mode 100644 index 0000000000000..14a942cd26844 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/custom_dashboards/get_custom_dashboards.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { + APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + SavedApmCustomDashboard, + ApmCustomDashboard, +} from '../../../common/custom_dashboards'; + +interface Props { + savedObjectsClient: SavedObjectsClientContract; +} + +export async function getCustomDashboards({ + savedObjectsClient, +}: Props): Promise { + const result = await savedObjectsClient.find({ + type: APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + page: 1, + perPage: 1000, + sortField: 'updated_at', + sortOrder: 'desc', + }); + + return result.saved_objects.map( + ({ id, attributes, updated_at: upatedAt }) => ({ + id, + updatedAt: upatedAt ? Date.parse(upatedAt) : 0, + ...attributes, + }) + ); +} diff --git a/x-pack/plugins/apm/server/routes/custom_dashboards/get_services_with_dashboards.ts b/x-pack/plugins/apm/server/routes/custom_dashboards/get_services_with_dashboards.ts new file mode 100644 index 0000000000000..23a77588eb6cd --- /dev/null +++ b/x-pack/plugins/apm/server/routes/custom_dashboards/get_services_with_dashboards.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server'; +import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { estypes } from '@elastic/elasticsearch'; +import { SERVICE_NAME } from '../../../common/es_fields/apm'; +import { + APMEventClient, + APMEventESSearchRequest, +} from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { SavedApmCustomDashboard } from '../../../common/custom_dashboards'; + +function getSearchRequest( + filters: estypes.QueryDslQueryContainer[] +): APMEventESSearchRequest { + return { + apm: { + events: [ProcessorEvent.metric, ProcessorEvent.transaction], + }, + body: { + track_total_hits: false, + terminate_after: 1, + size: 1, + query: { + bool: { + filter: filters, + }, + }, + }, + }; +} +export async function getServicesWithDashboards({ + apmEventClient, + allLinkedCustomDashboards, + serviceName, + start, + end, +}: { + apmEventClient: APMEventClient; + allLinkedCustomDashboards: SavedApmCustomDashboard[]; + serviceName: string; + start: number; + end: number; +}): Promise { + const allKueryPerDashboard = allLinkedCustomDashboards.map(({ kuery }) => ({ + kuery, + })); + const allSearches = allKueryPerDashboard.map((dashboard) => + getSearchRequest([ + ...kqlQuery(dashboard.kuery), + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ]) + ); + + const filteredDashboards = []; + + if (allSearches.length > 0) { + const allResponses = ( + await apmEventClient.msearch( + 'get_services_with_dashboards', + ...allSearches + ) + ).responses; + + for (let index = 0; index < allLinkedCustomDashboards.length; index++) { + const responsePerDashboard = allResponses[index]; + const dashboard = allLinkedCustomDashboards[index]; + + if (responsePerDashboard.hits.hits.length > 0) { + filteredDashboards.push(dashboard); + } + } + } + + return filteredDashboards; +} diff --git a/x-pack/plugins/apm/server/routes/custom_dashboards/remove_service_dashboard.ts b/x-pack/plugins/apm/server/routes/custom_dashboards/remove_service_dashboard.ts new file mode 100644 index 0000000000000..5a7a7b0d69e0e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/custom_dashboards/remove_service_dashboard.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../../common/custom_dashboards'; + +interface Options { + savedObjectsClient: SavedObjectsClientContract; + customDashboardId: string; +} +export async function deleteServiceDashboard({ + savedObjectsClient, + customDashboardId, +}: Options) { + return savedObjectsClient.delete( + APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + customDashboardId + ); +} diff --git a/x-pack/plugins/apm/server/routes/custom_dashboards/route.ts b/x-pack/plugins/apm/server/routes/custom_dashboards/route.ts new file mode 100644 index 0000000000000..256cd2fb3cba9 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/custom_dashboards/route.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { saveServiceDashbord } from './save_service_dashboard'; +import { SavedApmCustomDashboard } from '../../../common/custom_dashboards'; +import { deleteServiceDashboard } from './remove_service_dashboard'; +import { getCustomDashboards } from './get_custom_dashboards'; +import { getServicesWithDashboards } from './get_services_with_dashboards'; +import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; +import { rangeRt } from '../default_api_types'; + +const serviceDashboardSaveRoute = createApmServerRoute({ + endpoint: 'POST /internal/apm/custom-dashboard', + params: t.type({ + query: t.union([ + t.partial({ + customDashboardId: t.string, + }), + t.undefined, + ]), + body: t.type({ + dashboardSavedObjectId: t.string, + kuery: t.union([t.string, t.undefined]), + serviceNameFilterEnabled: t.boolean, + serviceEnvironmentFilterEnabled: t.boolean, + }), + }), + options: { tags: ['access:apm', 'access:apm_write'] }, + handler: async (resources): Promise => { + const { context, params } = resources; + const { customDashboardId } = params.query; + const { + savedObjects: { client: savedObjectsClient }, + } = await context.core; + + return saveServiceDashbord({ + savedObjectsClient, + customDashboardId, + serviceDashboard: params.body, + }); + }, +}); + +const serviceDashboardsRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/services/{serviceName}/dashboards', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: rangeRt, + }), + options: { + tags: ['access:apm'], + }, + handler: async ( + resources + ): Promise<{ serviceDashboards: SavedApmCustomDashboard[] }> => { + const { context, params } = resources; + const { start, end } = params.query; + + const { serviceName } = params.path; + + const apmEventClient = await getApmEventClient(resources); + + const { + savedObjects: { client: savedObjectsClient }, + } = await context.core; + + const allLinkedCustomDashboards = await getCustomDashboards({ + savedObjectsClient, + }); + + const servicesWithDashboards = await getServicesWithDashboards({ + apmEventClient, + allLinkedCustomDashboards, + serviceName, + start, + end, + }); + + return { serviceDashboards: servicesWithDashboards }; + }, +}); + +const serviceDashboardDeleteRoute = createApmServerRoute({ + endpoint: 'DELETE /internal/apm/custom-dashboard', + params: t.type({ + query: t.type({ + customDashboardId: t.string, + }), + }), + options: { tags: ['access:apm', 'access:apm_write'] }, + handler: async (resources): Promise => { + const { context, params } = resources; + const { customDashboardId } = params.query; + const savedObjectsClient = (await context.core).savedObjects.client; + await deleteServiceDashboard({ + savedObjectsClient, + customDashboardId, + }); + }, +}); + +export const serviceDashboardsRouteRepository = { + ...serviceDashboardSaveRoute, + ...serviceDashboardDeleteRoute, + ...serviceDashboardsRoute, +}; diff --git a/x-pack/plugins/apm/server/routes/custom_dashboards/save_service_dashboard.ts b/x-pack/plugins/apm/server/routes/custom_dashboards/save_service_dashboard.ts new file mode 100644 index 0000000000000..5c43dda2a4da5 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/custom_dashboards/save_service_dashboard.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { + APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + SavedApmCustomDashboard, + ApmCustomDashboard, +} from '../../../common/custom_dashboards'; + +interface Options { + savedObjectsClient: SavedObjectsClientContract; + customDashboardId?: string; + serviceDashboard: ApmCustomDashboard; +} +export async function saveServiceDashbord({ + savedObjectsClient, + customDashboardId, + serviceDashboard, +}: Options): Promise { + const { + id, + attributes, + updated_at: updatedAt, + } = await (customDashboardId + ? savedObjectsClient.update( + APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + customDashboardId, + serviceDashboard + ) + : savedObjectsClient.create( + APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + serviceDashboard + )); + return { + id, + ...(attributes as ApmCustomDashboard), + updatedAt: updatedAt ? Date.parse(updatedAt) : 0, + }; +} diff --git a/x-pack/plugins/apm/server/saved_objects/apm_custom_dashboards.ts b/x-pack/plugins/apm/server/saved_objects/apm_custom_dashboards.ts new file mode 100644 index 0000000000000..8d4b20757f136 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/apm_custom_dashboards.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../common/custom_dashboards'; + +export const apmCustomDashboards: SavedObjectsType = { + name: APM_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + mappings: { + properties: { + dashboardSavedObjectId: { type: 'keyword' }, + kuery: { type: 'text' }, + serviceEnvironmentFilterEnabled: { type: 'boolean' }, + serviceNameFilterEnabled: { type: 'boolean' }, + }, + }, + management: { + importableAndExportable: true, + icon: 'apmApp', + getTitle: () => + i18n.translate('xpack.apm.apmServiceDashboards.title', { + defaultMessage: 'APM Service Custom Dashboards', + }), + }, + modelVersions: { + '1': { + changes: [], + schemas: { + create: schema.object({ + dashboardSavedObjectId: schema.string(), + kuery: schema.maybe(schema.string()), + serviceEnvironmentFilterEnabled: schema.boolean(), + serviceNameFilterEnabled: schema.boolean(), + }), + }, + }, + }, +}; diff --git a/x-pack/plugins/apm/server/saved_objects/index.ts b/x-pack/plugins/apm/server/saved_objects/index.ts index b39e032ad14bd..effcedfc68932 100644 --- a/x-pack/plugins/apm/server/saved_objects/index.ts +++ b/x-pack/plugins/apm/server/saved_objects/index.ts @@ -8,3 +8,4 @@ export { apmTelemetry } from './apm_telemetry'; export { apmServerSettings } from './apm_server_settings'; export { apmServiceGroups } from './apm_service_groups'; +export { apmCustomDashboards } from './apm_custom_dashboards'; diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json index 7a22ac6e4a4c2..2c225c509fad6 100644 --- a/x-pack/plugins/apm/tsconfig.json +++ b/x-pack/plugins/apm/tsconfig.json @@ -102,6 +102,7 @@ "@kbn/core-analytics-server", "@kbn/analytics-client", "@kbn/monaco", + "@kbn/shared-svg", "@kbn/deeplinks-observability" ], "exclude": ["target/**/*"] diff --git a/x-pack/test/apm_api_integration/tests/custom_dashboards/api_helper.ts b/x-pack/test/apm_api_integration/tests/custom_dashboards/api_helper.ts new file mode 100644 index 0000000000000..a0fb0e976d109 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/custom_dashboards/api_helper.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApmApiClient } from '../../common/config'; + +export async function getServiceDashboardApi( + apmApiClient: ApmApiClient, + serviceName: string, + start: string, + end: string +) { + return apmApiClient.writeUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/dashboards', + params: { + path: { serviceName }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); +} + +export async function getLinkServiceDashboardApi({ + dashboardSavedObjectId, + apmApiClient, + customDashboardId, + kuery, + serviceFiltersEnabled, +}: { + apmApiClient: ApmApiClient; + dashboardSavedObjectId: string; + customDashboardId?: string; + kuery: string; + serviceFiltersEnabled: boolean; +}) { + const response = await apmApiClient.writeUser({ + endpoint: 'POST /internal/apm/custom-dashboard', + params: { + query: { + customDashboardId, + }, + body: { + dashboardSavedObjectId, + kuery, + serviceEnvironmentFilterEnabled: serviceFiltersEnabled, + serviceNameFilterEnabled: serviceFiltersEnabled, + }, + }, + }); + return response; +} + +export async function deleteAllServiceDashboard( + apmApiClient: ApmApiClient, + serviceName: string, + start: string, + end: string +) { + return await getServiceDashboardApi(apmApiClient, serviceName, start, end).then((response) => { + const promises = response.body.serviceDashboards.map((item) => { + if (item.id) { + return apmApiClient.writeUser({ + endpoint: 'DELETE /internal/apm/custom-dashboard', + params: { query: { customDashboardId: item.id } }, + }); + } + }); + return Promise.all(promises); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/custom_dashboards/custom_dashboards.spec.ts b/x-pack/test/apm_api_integration/tests/custom_dashboards/custom_dashboards.spec.ts new file mode 100644 index 0000000000000..773e2bb06686d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/custom_dashboards/custom_dashboards.spec.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; + +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + getServiceDashboardApi, + getLinkServiceDashboardApi, + deleteAllServiceDashboard, +} from './api_helper'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtrace = getService('synthtraceEsClient'); + + const start = '2023-08-22T00:00:00.000Z'; + const end = '2023-08-22T00:15:00.000Z'; + + registry.when( + 'Service dashboards when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when data is not loaded', () => { + it('handles empty state', async () => { + const response = await getServiceDashboardApi(apmApiClient, 'synth-go', start, end); + expect(response.status).to.be(200); + expect(response.body.serviceDashboards).to.eql([]); + }); + }); + } + ); + + registry.when('Service dashboards when data is loaded', { config: 'basic', archives: [] }, () => { + const range = timerange(new Date(start).getTime(), new Date(end).getTime()); + + const goInstance = apm + .service({ + name: 'synth-go', + environment: 'production', + agentName: 'go', + }) + .instance('go-instance'); + + const javaInstance = apm + .service({ + name: 'synth-java', + environment: 'production', + agentName: 'java', + }) + .instance('java-instance'); + + before(async () => { + return synthtrace.index([ + range + .interval('1s') + .rate(4) + .generator((timestamp) => + goInstance + .transaction({ transactionName: 'GET /api' }) + .timestamp(timestamp) + .duration(1000) + .success() + ), + range + .interval('1s') + .rate(4) + .generator((timestamp) => + javaInstance + .transaction({ transactionName: 'GET /api' }) + .timestamp(timestamp) + .duration(1000) + .success() + ), + ]); + }); + + after(() => { + return synthtrace.clean(); + }); + + afterEach(async () => { + await deleteAllServiceDashboard(apmApiClient, 'synth-go', start, end); + }); + + describe('when data is not loaded', () => { + it('creates a new service dashboard', async () => { + const serviceDashboard = { + dashboardSavedObjectId: 'dashboard-saved-object-id', + serviceFiltersEnabled: true, + kuery: 'service.name: synth-go', + }; + const createResponse = await getLinkServiceDashboardApi({ + apmApiClient, + ...serviceDashboard, + }); + expect(createResponse.status).to.be(200); + expect(createResponse.body).to.have.property('id'); + expect(createResponse.body).to.have.property('updatedAt'); + + expect(createResponse.body).to.have.property( + 'dashboardSavedObjectId', + serviceDashboard.dashboardSavedObjectId + ); + expect(createResponse.body).to.have.property('kuery', serviceDashboard.kuery); + expect(createResponse.body).to.have.property( + 'serviceEnvironmentFilterEnabled', + serviceDashboard.serviceFiltersEnabled + ); + expect(createResponse.body).to.have.property( + 'serviceNameFilterEnabled', + serviceDashboard.serviceFiltersEnabled + ); + + const dasboardForGoService = await getServiceDashboardApi( + apmApiClient, + 'synth-go', + start, + end + ); + const dashboardForJavaService = await getServiceDashboardApi( + apmApiClient, + 'synth-java', + start, + end + ); + expect(dashboardForJavaService.body.serviceDashboards.length).to.be(0); + expect(dasboardForGoService.body.serviceDashboards.length).to.be(1); + }); + + it('updates the existing linked service dashboard', async () => { + const serviceDashboard = { + dashboardSavedObjectId: 'dashboard-saved-object-id', + serviceFiltersEnabled: true, + kuery: 'service.name: synth-go or agent.name: java', + }; + + await getLinkServiceDashboardApi({ + apmApiClient, + ...serviceDashboard, + }); + + const dasboardForGoService = await getServiceDashboardApi( + apmApiClient, + 'synth-go', + start, + end + ); + + const updateResponse = await getLinkServiceDashboardApi({ + apmApiClient, + customDashboardId: dasboardForGoService.body.serviceDashboards[0].id, + ...serviceDashboard, + serviceFiltersEnabled: true, + }); + + expect(updateResponse.status).to.be(200); + + const updateddasboardForGoService = await getServiceDashboardApi( + apmApiClient, + 'synth-go', + start, + end + ); + expect(updateddasboardForGoService.body.serviceDashboards.length).to.be(1); + expect(updateddasboardForGoService.body.serviceDashboards[0]).to.have.property( + 'serviceEnvironmentFilterEnabled', + true + ); + expect(updateddasboardForGoService.body.serviceDashboards[0]).to.have.property( + 'serviceNameFilterEnabled', + true + ); + expect(updateddasboardForGoService.body.serviceDashboards[0]).to.have.property( + 'kuery', + 'service.name: synth-go or agent.name: java' + ); + + const dashboardForJavaService = await getServiceDashboardApi( + apmApiClient, + 'synth-java', + start, + end + ); + expect(dashboardForJavaService.body.serviceDashboards.length).to.be(1); + }); + }); + }); +}