[APM] Introduce custom dashboards tab in service overview (#166789)

## Summary




3598bfb7-83fc-4eb0-b185-20d689442a68





### Notes

1. ~~Storing the dashboard name in the saved object may become outdated
and cause confusion, as users have the ability to update the dashboard
title on the dashboard page.
 [**UPDATED**]  Fetch dynamically from the dashboard module api
3. UI we don't have  an indicator useContextFilter 

## TODO

- [x] API tests
- [x] Dynamic title
- [x] Deep-link for dashboard
- [x] Fetch services that match the dashboard kuery

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Katerina 2023-10-03 11:55:39 +02:00 committed by GitHub
parent 0c6dfbf209
commit 17f633c420
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1750 additions and 5 deletions

View file

@ -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": {}

View file

@ -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 };

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 144 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -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",

View file

@ -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',

View file

@ -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",

View file

@ -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;
}

View file

@ -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);

View file

@ -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 (
<>
<EuiButtonEmpty
color="text"
size="s"
iconType={'pencil'}
data-test-subj="apmEditServiceDashboardMenu"
onClick={() => setIsModalVisible(!isModalVisible)}
>
{i18n.translate('xpack.apm.serviceDashboards.editEmptyButtonLabel', {
defaultMessage: 'Edit dashboard link',
})}
</EuiButtonEmpty>
{isModalVisible && (
<SaveDashboardModal
onClose={() => setIsModalVisible(!isModalVisible)}
onRefresh={onRefresh}
currentDashboard={currentDashboard}
/>
)}
</>
);
}

View file

@ -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<ApmPluginStartDeps>();
const url = dashboardLocator?.getRedirectUrl({
dashboardId: currentDashboard?.dashboardSavedObjectId,
});
return (
<EuiButtonEmpty
data-test-subj="apmGotoDashboardGoToDashboardButton"
color="text"
size="s"
iconType={'visGauge'}
href={url}
>
{i18n.translate('xpack.apm.serviceDashboards.contextMenu.goToDashboard', {
defaultMessage: 'Go to dashboard',
})}
</EuiButtonEmpty>
);
}

View file

@ -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 };

View file

@ -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 ? (
<EuiButtonEmpty
color="text"
size="s"
iconType={'plusInCircle'}
data-test-subj="apmLinkServiceDashboardMenu"
onClick={() => setIsModalVisible(true)}
>
{i18n.translate('xpack.apm.serviceDashboards.linkEmptyButtonLabel', {
defaultMessage: 'Link new dashboard',
})}
</EuiButtonEmpty>
) : (
<EuiButton
data-test-subj="apmAddServiceDashboard"
onClick={() => setIsModalVisible(true)}
>
{i18n.translate('xpack.apm.serviceDashboards.linkButtonLabel', {
defaultMessage: 'Link dashboard',
})}
</EuiButton>
)}
{isModalVisible && (
<SaveDashboardModal
onClose={() => setIsModalVisible(false)}
onRefresh={onRefresh}
serviceDashboards={serviceDashboards}
/>
)}
</>
);
}

View file

@ -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<string> | 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 (
<EuiModal onClose={onClose} data-test-subj="apmSelectServiceDashboard">
<EuiModalHeader>
<EuiModalHeaderTitle>
{isEditMode
? i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.modalTitle.edit',
{
defaultMessage: 'Edit dashboard',
}
)
: i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.modalTitle.link',
{
defaultMessage: 'Select dashboard',
}
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup direction="column" justifyContent="center">
<EuiComboBox
isLoading={status === FETCH_STATUS.LOADING}
isDisabled={status === FETCH_STATUS.LOADING || isEditMode}
placeholder={i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.placeholder',
{
defaultMessage: 'Select dasbboard',
}
)}
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedDashboard}
onChange={(newSelection) => setSelectedDashboard(newSelection)}
isClearable={true}
/>
<EuiSwitch
css={{ alignItems: 'center' }}
compressed
label={
<p>
{i18n.translate(
'xpack.apm.dashboard.addDashboard.useContextFilterLabel',
{
defaultMessage: 'Filter by service and environment',
}
)}{' '}
<EuiToolTip
position="bottom"
content={i18n.translate(
'xpack.apm.dashboard.addDashboard.useContextFilterLabel.tooltip',
{
defaultMessage:
'Enabling this option will apply filters to the dashboard based on your chosen service and environment.',
}
)}
>
<EuiIcon type="questionInCircle" title="Icon with tooltip" />
</EuiToolTip>
</p>
}
onChange={() => setserviceFiltersEnabled(!serviceFiltersEnabled)}
checked={serviceFiltersEnabled}
/>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="apmSelectDashboardCancelButton"
onClick={onClose}
>
{i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.cancel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
<EuiButton
data-test-subj="apmSelectDashboardButton"
onClick={onSave}
fill
>
{isEditMode
? i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.edit',
{
defaultMessage: 'Save',
}
)
: i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.add',
{
defaultMessage: 'Link dashboard',
}
)}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
}
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',
}),
};
}

View file

@ -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 (
<>
<EuiButtonEmpty
color="danger"
size="s"
iconType={'unlink'}
data-test-subj="apmUnLinkServiceDashboardMenu"
onClick={() => setIsModalVisible(true)}
>
{i18n.translate('xpack.apm.serviceDashboards.unlinkEmptyButtonLabel', {
defaultMessage: 'Unlink dashboard',
})}
</EuiButtonEmpty>
{isModalVisible && (
<EuiConfirmModal
title={i18n.translate(
'xpack.apm.serviceDashboards.unlinkEmptyButtonLabel.confirm.title',
{
defaultMessage: 'Unlink Dashboard',
}
)}
onCancel={() => setIsModalVisible(false)}
onConfirm={onConfirm}
confirmButtonText={i18n.translate(
'xpack.apm.serviceDashboards.unlinkEmptyButtonLabel.confirm.button',
{
defaultMessage: 'Unlink dashboard',
}
)}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>
{i18n.translate(
'xpack.apm.serviceDashboards.unlinkEmptyButtonLabel.confirm.body',
{
defaultMessage:
'You are about to unlink the dashboard from the service context',
}
)}
</p>
</EuiConfirmModal>
)}
</>
);
}

View file

@ -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 (
<EuiPopover
button={
<EuiButtonIcon
data-test-subj="apmContextMenuButton"
display="base"
size="s"
iconType="boxesVertical"
aria-label="More"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={items.map((item: React.ReactNode) => (
<EuiContextMenuItem size="s"> {item}</EuiContextMenuItem>
))}
/>
</EuiPopover>
);
}

View file

@ -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 (
<EuiComboBox
compressed
style={{ minWidth: '200px' }}
placeholder={i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.placeholder',
{
defaultMessage: 'Select dasbboard',
}
)}
prepend={i18n.translate(
'xpack.apm.serviceDashboards.selectDashboard.prepend',
{
defaultMessage: 'Dashboard',
}
)}
singleSelection={{ asPlainText: true }}
options={serviceDashboards.map(({ dashboardSavedObjectId, title }) => {
return {
label: title,
value: dashboardSavedObjectId,
};
})}
selectedOptions={
currentDashboard
? [
{
value: currentDashboard?.dashboardSavedObjectId,
label: currentDashboard?.title,
},
]
: []
}
onChange={([newItem]) => onChange(newItem.value)}
isClearable={false}
/>
);
}

View file

@ -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 (
<>
<EuiEmptyPrompt
hasShadow={false}
hasBorder={false}
icon={
<EuiImage
size="fullWidth"
src={theme.darkMode ? dashboardsDark : dashboardsLight}
alt=""
/>
}
title={
<h2>
{i18n.translate('xpack.apm.serviceDashboards.emptyTitle', {
defaultMessage:
'The best way to understand your data is to visualize it.',
})}
</h2>
}
layout="horizontal"
color="plain"
body={
<>
<ul>
<li>
{i18n.translate('xpack.apm.serviceDashboards.emptyBody.first', {
defaultMessage: 'bring clarity to your data',
})}
</li>
<li>
{i18n.translate(
'xpack.apm.serviceDashboards.emptyBody.second',
{
defaultMessage: 'tell a story about your data',
}
)}
</li>
<li>
{i18n.translate('xpack.apm.serviceDashboards.emptyBody', {
defaultMessage:
'focus on only the data thats important to you',
})}
</li>
</ul>
<p>
{i18n.translate(
'xpack.apm.serviceDashboards.emptyBody.getStarted',
{
defaultMessage: 'To get started, add your dashaboard',
}
)}
</p>
</>
}
actions={actions}
/>
</>
);
}

View file

@ -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<AwaitingDashboardAPI>();
const [serviceDashboards, setServiceDashboards] = useState<
MergedServiceDashboard[]
>([]);
const [currentDashboard, setCurrentDashboard] =
useState<MergedServiceDashboard>();
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<DashboardCreationOptions> => {
const getInitialInput = () => ({
viewMode: ViewMode.VIEW,
timeRange: { from: rangeFrom, to: rangeTo },
});
return Promise.resolve<DashboardCreationOptions>({ 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 (
<EuiPanel hasBorder={true}>
{status === FETCH_STATUS.LOADING ? (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoObservability" size="xl" />}
title={
<h4>
{i18n.translate(
'xpack.apm.serviceDashboards.loadingServiceDashboards',
{
defaultMessage: 'Loading service dashboard',
}
)}
</h4>
}
/>
) : status === FETCH_STATUS.SUCCESS && serviceDashboards?.length > 0 ? (
<>
<EuiFlexGroup
justifyContent="spaceBetween"
gutterSize="xs"
alignItems="center"
>
<EuiFlexItem grow={true}>
<EuiTitle size="s">
<h3>{currentDashboard?.title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DashboardSelector
serviceDashboards={serviceDashboards}
handleOnChange={handleOnChange}
currentDashboard={currentDashboard}
/>
</EuiFlexItem>
{currentDashboard && (
<EuiFlexItem grow={false}>
<ContextMenu
items={[
<LinkDashboard
emptyButton={true}
onRefresh={refetch}
serviceDashboards={serviceDashboards}
/>,
<GotoDashboard currentDashboard={currentDashboard} />,
<EditDashboard
currentDashboard={currentDashboard}
onRefresh={refetch}
/>,
<UnlinkDashboard
currentDashboard={currentDashboard}
onRefresh={refetch}
/>,
]}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexItem grow={true}>
<EuiSpacer size="l" />
{currentDashboard && (
<DashboardRenderer
savedObjectId={currentDashboard.dashboardSavedObjectId}
getCreationOptions={getCreationOptions}
ref={setDashboard}
/>
)}
</EuiFlexItem>
</>
) : (
<EmptyDashboards actions={<LinkDashboard onRefresh={refetch} />} />
)}
</EuiPanel>
);
}

View file

@ -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: <ServiceDashboards />,
}),
params: t.partial({
query: t.partial({
dashboardId: t.string,
}),
}),
},
'/services/{serviceName}/': {
element: <RedirectToDefaultServiceRouteView />,
},

View file

@ -63,7 +63,8 @@ type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
| 'service-map'
| 'logs'
| 'alerts'
| 'profiling';
| 'profiling'
| 'dashboards';
hidden?: boolean;
};
@ -417,6 +418,17 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) {
</EuiBadge>
),
},
{
key: 'dashboards',
href: router.link('/services/{serviceName}/dashboards', {
path: { serviceName },
query,
}),
label: i18n.translate('xpack.apm.home.dashboardsTabLabel', {
defaultMessage: 'Dashboards',
}),
append: <TechnicalPreviewBadge icon="beaker" />,
},
];
return tabs

View file

@ -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<ApmPluginStartDeps>();
const [result, setResult] = useState<SearchDashboardsResult>({
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;
}

View file

@ -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<ApmPlugin['setup']>;
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', {

View file

@ -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<APMConfig>();
this.currentConfig = currentConfig;

View file

@ -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;

View file

@ -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<SavedApmCustomDashboard[]> {
const result = await savedObjectsClient.find<ApmCustomDashboard>({
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,
})
);
}

View file

@ -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<SavedApmCustomDashboard[]> {
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;
}

View file

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

View file

@ -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<SavedApmCustomDashboard> => {
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<void> => {
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,
};

View file

@ -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<SavedApmCustomDashboard> {
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,
};
}

View file

@ -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(),
}),
},
},
},
};

View file

@ -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';

View file

@ -102,6 +102,7 @@
"@kbn/core-analytics-server",
"@kbn/analytics-client",
"@kbn/monaco",
"@kbn/shared-svg",
"@kbn/deeplinks-observability"
],
"exclude": ["target/**/*"]

View file

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

View file

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