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);
+ });
+ });
+ });
+}