mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
0c6dfbf209
commit
17f633c420
35 changed files with 1750 additions and 5 deletions
|
@ -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": {}
|
||||
|
|
|
@ -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 };
|
||||
|
|
1
packages/kbn-shared-svg/src/assets/dashboards_dark.svg
Normal file
1
packages/kbn-shared-svg/src/assets/dashboards_dark.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 144 KiB |
1
packages/kbn-shared-svg/src/assets/dashboards_light.svg
Normal file
1
packages/kbn-shared-svg/src/assets/dashboards_light.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 145 KiB |
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
|
|
20
x-pack/plugins/apm/common/custom_dashboards.ts
Normal file
20
x-pack/plugins/apm/common/custom_dashboards.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 that’s important to you',
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDashboards.emptyBody.getStarted',
|
||||
{
|
||||
defaultMessage: 'To get started, add your dashaboard',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
actions={actions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />,
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
56
x-pack/plugins/apm/public/hooks/use_dashboards_fetcher.ts
Normal file
56
x-pack/plugins/apm/public/hooks/use_dashboards_fetcher.ts
Normal 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;
|
||||
}
|
|
@ -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', {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
114
x-pack/plugins/apm/server/routes/custom_dashboards/route.ts
Normal file
114
x-pack/plugins/apm/server/routes/custom_dashboards/route.ts
Normal 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,
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
"@kbn/core-analytics-server",
|
||||
"@kbn/analytics-client",
|
||||
"@kbn/monaco",
|
||||
"@kbn/shared-svg",
|
||||
"@kbn/deeplinks-observability"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue