From 2817d6e3a8eb3fc1705883e82411876b134a6b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 6 Mar 2020 10:06:16 +0000 Subject: [PATCH] [APM] Create settings page to manage Custom Links (#57788) * creating custom action index * reverting service form to service section * creating useForm hooks and fields section * adding react-hook-form * refactoring * validating filters * fixing imports * refactoring to NP and creating save custom action * creating basic apis for custom actions * refactoring * changing custom action filters type * adding delete option * removing useForm * fixing flyout view * filters are invalid when selecting the default value * ui fixes * ui fixes * fixing typescript * fixing typescript * fixing labels and adding space btw components * refactoring filters structure * removing reach-hook-form * removing reach-hook-form * adding unit tests * adding unit tests * create custom action index * adding filter option * refactoring create index, creating filter links * creating list api * rename custom action to custom link * fixing unit tests * adding unit tests * refactoring callApmApi * removing useCallApmApi hook * Rename Flyoutfooter.tsx to FlyoutFooter.tsx * removing unused import * fixing typescript errors * fixing duplicate messages * removing filters * fixing save functionality * fixing pr comments * fixing pr comments --- .../app/Home/__snapshots__/Home.test.tsx.snap | 2 + .../components/app/ServiceMap/index.tsx | 5 +- .../AddEditFlyout/DeleteButton.tsx | 7 +- .../AddEditFlyout/ServiceSection.tsx} | 22 +- .../AddEditFlyout/index.tsx | 8 +- .../AddEditFlyout/saveConfig.ts | 4 +- .../app/Settings/ApmIndices/index.tsx | 18 +- .../CustomActionsFlyout/SettingsSection.tsx | 81 ------ .../CustomActionsFlyout/index.tsx | 109 -------- .../CustomActionsOverview/EmptyPrompt.tsx | 52 ---- .../__test__/CustomActions.test.tsx | 33 --- .../CustomActionsOverview/index.tsx | 75 ------ .../CustomLink/CreateCustomLinkButton.tsx | 21 ++ .../CustomLinkFlyout/DeleteButton.tsx | 70 +++++ .../CustomLinkFlyout/FiltersSection.tsx | 167 ++++++++++++ .../CustomLinkFlyout/FlyoutFooter.tsx | 70 +++++ .../CustomLinkFlyout/LinkSection.tsx | 135 ++++++++++ .../CustomLink/CustomLinkFlyout/helper.ts | 96 +++++++ .../CustomLink/CustomLinkFlyout/index.tsx | 121 +++++++++ .../CustomLinkFlyout/saveCustomLink.ts | 73 +++++ .../CustomLink/CustomLinkTable.tsx | 140 ++++++++++ .../CustomizeUI/CustomLink/EmptyPrompt.tsx | 46 ++++ .../Title.tsx | 8 +- .../CustomLink/__test__/CustomLink.test.tsx | 251 ++++++++++++++++++ .../Settings/CustomizeUI/CustomLink/index.tsx | 92 +++++++ .../app/Settings/CustomizeUI/index.tsx | 4 +- .../plugins/apm/public/hooks/useCallApmApi.ts | 17 -- .../plugins/apm/public/hooks/useFetcher.tsx | 5 +- .../apm/public/new-platform/plugin.tsx | 4 +- .../services/__test__/callApmApi.test.ts | 5 +- .../public/services/rest/createCallApmApi.ts | 11 +- .../apm/public/services/rest/index_pattern.ts | 6 +- .../plugins/apm/public/services/rest/ml.ts | 3 +- .../plugins/apm/public/utils/testHelpers.tsx | 11 +- .../__tests__/get_buckets.test.ts | 3 +- .../lib/helpers/create_or_update_index.ts | 91 +++++++ .../create_agent_config_index.ts | 126 ++++----- .../settings/apm_indices/get_apm_indices.ts | 4 +- .../list_custom_links.test.ts.snap | 76 ++++++ .../create_or_update_custom_link.test.ts | 70 +++++ .../__test__/list_custom_links.test.ts | 45 ++++ .../custom_link/create_custom_link_index.ts | 60 +++++ .../create_or_update_custom_link.ts | 41 +++ .../custom_link/custom_link_types.d.ts | 14 + .../custom_link/delete_custom_link.ts | 25 ++ .../settings/custom_link/list_custom_links.ts | 48 ++++ .../lib/transaction_groups/fetcher.test.ts | 3 +- .../lib/transactions/breakdown/index.test.ts | 3 +- .../charts/get_anomaly_data/index.test.ts | 3 +- .../get_timeseries_data/fetcher.test.ts | 3 +- x-pack/plugins/apm/server/plugin.ts | 7 + .../apm/server/routes/create_apm_api.ts | 14 +- .../apm/server/routes/settings/custom_link.ts | 117 ++++++++ 53 files changed, 2009 insertions(+), 516 deletions(-) rename x-pack/legacy/plugins/apm/public/components/{shared/ServiceForm/index.tsx => app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx} (82%) delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx rename x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/{CustomActionsOverview => CustomLink}/Title.tsx (81%) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts create mode 100644 x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts create mode 100644 x-pack/plugins/apm/server/routes/settings/custom_link.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 7809734dbf2a..d5764001a7f1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -22,6 +22,7 @@ exports[`Home component should render services 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, @@ -61,6 +62,7 @@ exports[`Home component should render traces 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index d5f0728a7ff1..9a93c67f0818 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -21,7 +21,6 @@ import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; -import { useCallApmApi } from '../../../hooks/useCallApmApi'; import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; import { useLicense } from '../../../hooks/useLicense'; import { useLoadingIndicator } from '../../../hooks/useLoadingIndicator'; @@ -33,6 +32,7 @@ import { getCytoscapeElements } from './get_cytoscape_elements'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; interface ServiceMapProps { serviceName?: string; @@ -61,7 +61,6 @@ ${theme.euiColorLightShade}`, const MAX_REQUESTS = 5; export function ServiceMap({ serviceName }: ServiceMapProps) { - const callApmApi = useCallApmApi(); const license = useLicense(); const { search } = useLocation(); const { urlParams, uiFilters } = useUrlParams(); @@ -137,7 +136,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { } } }, - [params, setIsLoading, callApmApi, responses.length, notifications.toasts] + [params, setIsLoading, responses.length, notifications.toasts] ); useEffect(() => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx index 1564f1ae746a..997df371b51e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx @@ -8,10 +8,9 @@ import React, { useState } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useCallApmApi } from '../../../../../hooks/useCallApmApi'; import { Config } from '../index'; import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { APMClient } from '../../../../../services/rest/createCallApmApi'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; interface Props { @@ -22,7 +21,6 @@ interface Props { export function DeleteButton({ onDeleted, selectedConfig }: Props) { const [isDeleting, setIsDeleting] = useState(false); const { toasts } = useApmPluginContext().core.notifications; - const callApmApi = useCallApmApi(); return ( { setIsDeleting(true); - await deleteConfig(callApmApi, selectedConfig, toasts); + await deleteConfig(selectedConfig, toasts); setIsDeleting(false); onDeleted(); }} @@ -45,7 +43,6 @@ export function DeleteButton({ onDeleted, selectedConfig }: Props) { } async function deleteConfig( - callApmApi: APMClient, selectedConfig: Config, toasts: NotificationsStart['toasts'] ) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx similarity index 82% rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx index ab3accec90d1..537bdace50e2 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx @@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n'; import { omitAllOption, getOptionLabel -} from '../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../SelectWithPlaceholder'; +} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.selectPlaceholder', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', { defaultMessage: 'Select' } )} -`; @@ -27,7 +27,7 @@ interface Props { onEnvironmentChange: (env: string) => void; } -export function ServiceForm({ +export function ServiceSection({ isReadOnly, serviceName, onServiceNameChange, @@ -60,7 +60,7 @@ export function ServiceForm({ ); const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.alreadyConfiguredOption', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', { defaultMessage: 'already configured' } ); @@ -83,7 +83,7 @@ export function ServiceForm({

{i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.title', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', { defaultMessage: 'Service' } )}

@@ -93,13 +93,13 @@ export function ServiceForm({ - ; }) { await callApmApi({ @@ -94,11 +91,11 @@ export function ApmIndices() { const [apmIndices, setApmIndices] = useState>({}); const [isSaving, setIsSaving] = useState(false); - const callApmApiFromHook = useCallApmApi(); - const { data = INITIAL_STATE, status, refetch } = useFetcher( - callApmApi => - callApmApi({ pathname: `/api/apm/settings/apm-index-settings` }), + _callApmApi => + _callApmApi({ + pathname: `/api/apm/settings/apm-index-settings` + }), [] ); @@ -122,10 +119,7 @@ export function ApmIndices() { event.preventDefault(); setIsSaving(true); try { - await saveApmIndices({ - callApmApi: callApmApiFromHook, - apmIndices - }); + await saveApmIndices({ apmIndices }); toasts.addSuccess({ title: i18n.translate( 'xpack.apm.settings.apmIndices.applyChanges.succeeded.title', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx deleted file mode 100644 index 8cb604d36754..000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiFieldText, EuiFormRow, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface Props { - label: string; - onLabelChange: (label: string) => void; - url: string; - onURLChange: (url: string) => void; -} - -export const SettingsSection = ({ - label, - onLabelChange, - url, - onURLChange -}: Props) => { - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.title', - { defaultMessage: 'Action' } - )} -

-
- - - { - onLabelChange(e.target.value); - }} - /> - - - { - onURLChange(e.target.value); - }} - /> - - - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx deleted file mode 100644 index d04cdd62c303..000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiPortal, - EuiSpacer, - EuiText, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { SettingsSection } from './SettingsSection'; -import { ServiceForm } from '../../../../../shared/ServiceForm'; - -interface Props { - onClose: () => void; -} - -export const CustomActionsFlyout = ({ onClose }: Props) => { - const [serviceName, setServiceName] = useState(''); - const [environment, setEnvironment] = useState(''); - const [label, setLabel] = useState(''); - const [url, setURL] = useState(''); - return ( - - - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.title', - { - defaultMessage: 'Create custom action' - } - )} -

-
-
- - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.label', - { - defaultMessage: - "This action will be shown in the 'Actions' context menu for the trace and error detail components. You can specify any number of links, but only the first three will be shown, in alphabetical order." - } - )} -

-
- - - - - - -
- - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.close', - { - defaultMessage: 'Close' - } - )} - - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.save', - { - defaultMessage: 'Save' - } - )} - - - - -
-
- ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx deleted file mode 100644 index f39e4b307b24..000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -export const EmptyPrompt = ({ - onCreateCustomActionClick -}: { - onCreateCustomActionClick: () => void; -}) => { - return ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptTitle', - { - defaultMessage: 'No actions found.' - } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptText', - { - defaultMessage: - "Let's change that! You can add custom actions to the Actions context menu by the trace and error details for each service. This could be linking to a Kibana dashboard or going to your organization's support portal" - } - )} -

- - } - actions={ - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.createCustomAction', - { defaultMessage: 'Create custom action' } - )} - - } - /> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx deleted file mode 100644 index 970de66c64a9..000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { CustomActionsOverview } from '../'; -import { expectTextsInDocument } from '../../../../../../utils/testHelpers'; -import * as hooks from '../../../../../../hooks/useFetcher'; - -describe('CustomActions', () => { - afterEach(() => jest.restoreAllMocks()); - - describe('empty prompt', () => { - it('shows when any actions are available', () => { - // TODO: mock return items - const component = render(); - expectTextsInDocument(component, ['No actions found.']); - }); - it('opens flyout when click to create new action', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: [], - status: 'success' - }); - const { queryByText, getByText } = render(); - expect(queryByText('Service')).not.toBeInTheDocument(); - fireEvent.click(getByText('Create custom action')); - expect(queryByText('Service')).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx deleted file mode 100644 index ae2972f251fc..000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiSpacer } from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { useState } from 'react'; -import { ManagedTable } from '../../../../shared/ManagedTable'; -import { Title } from './Title'; -import { EmptyPrompt } from './EmptyPrompt'; -import { CustomActionsFlyout } from './CustomActionsFlyout'; - -export const CustomActionsOverview = () => { - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - - // TODO: change it to correct fields fetched from ES - const columns = [ - { - field: 'actionName', - name: 'Action Name', - truncateText: true - }, - { - field: 'serviceName', - name: 'Service Name' - }, - { - field: 'environment', - name: 'Environment' - }, - { - field: 'lastUpdate', - name: 'Last update' - }, - { - field: 'actions', - name: 'Actions' - } - ]; - - // TODO: change to items fetched from ES. - const items: object[] = []; - - const onCloseFlyout = () => { - setIsFlyoutOpen(false); - }; - - const onCreateCustomActionClick = () => { - setIsFlyoutOpen(true); - }; - - return ( - <> - - - <EuiSpacer size="m" /> - {isFlyoutOpen && <CustomActionsFlyout onClose={onCloseFlyout} />} - {isEmpty(items) ? ( - <EmptyPrompt onCreateCustomActionClick={onCreateCustomActionClick} /> - ) : ( - <ManagedTable - items={items} - columns={columns} - initialPageSize={25} - initialSortField="occurrenceCount" - initialSortDirection="desc" - sortItems={false} - /> - )} - </EuiPanel> - </> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx new file mode 100644 index 000000000000..415d2557c23c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const CreateCustomLinkButton = ({ + onClick +}: { + onClick: () => void; +}) => ( + <EuiButton color="primary" fill onClick={onClick}> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.createCustomLink', + { defaultMessage: 'Create custom link' } + )} + </EuiButton> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx new file mode 100644 index 000000000000..2b3a5cbe8799 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import React, { useState } from 'react'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; + +interface Props { + onDelete: () => void; + customLinkId: string; +} + +export function DeleteButton({ onDelete, customLinkId }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + <EuiButtonEmpty + color="danger" + isLoading={isDeleting} + iconSide="right" + onClick={async () => { + setIsDeleting(true); + await deleteConfig(customLinkId, toasts); + setIsDeleting(false); + onDelete(); + }} + > + {i18n.translate('xpack.apm.settings.customizeUI.customLink.delete', { + defaultMessage: 'Delete' + })} + </EuiButtonEmpty> + ); +} + +async function deleteConfig( + customLinkId: string, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'DELETE', + params: { + path: { id: customLinkId } + } + }); + toasts.addSuccess({ + iconType: 'trash', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.successed', + { defaultMessage: 'Deleted custom link.' } + ) + }); + } catch (error) { + toasts.addDanger({ + iconType: 'cross', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.failed', + { defaultMessage: 'Custom link could not be deleted' } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx new file mode 100644 index 000000000000..69fecf25f514 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { + DEFAULT_OPTION, + Filters, + filterSelectOptions, + getSelectOptions +} from './helper'; + +export const FiltersSection = ({ + filters, + onChangeFilters +}: { + filters: Filters; + onChangeFilters: (filters: Filters) => void; +}) => { + const onChangeFilter = (filter: Filters[0], idx: number) => { + const newFilters = [...filters]; + newFilters[idx] = filter; + onChangeFilters(newFilters); + }; + + const onRemoveFilter = (idx: number) => { + // remove without mutating original array + const newFilters = [...filters].splice(idx, 1); + + // if there is only one item left it should not be removed + // but reset to empty + if (isEmpty(newFilters)) { + onChangeFilters([['', '']]); + } else { + onChangeFilters(newFilters); + } + }; + + const handleAddFilter = () => { + onChangeFilters([...filters, ['', '']]); + }; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.title', + { + defaultMessage: 'Filters' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle', + { + defaultMessage: + 'Add additional values within the same field by comma separating values.' + } + )} + </EuiText> + + <EuiSpacer size="s" /> + + {filters.map((filter, idx) => { + const [key, value] = filter; + const filterId = `filter-${idx}`; + const selectOptions = getSelectOptions(filters, idx); + return ( + <EuiFlexGroup key={filterId} gutterSize="s" alignItems="center"> + <EuiFlexItem> + <EuiSelect + aria-label={filterId} + id={filterId} + fullWidth + options={selectOptions} + value={key} + prepend={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.prepend', + { + defaultMessage: 'Field' + } + )} + onChange={e => + onChangeFilter( + [e.target.value as keyof FilterOptions, value], + idx + ) + } + isInvalid={ + !isEmpty(value) && + (isEmpty(key) || key === DEFAULT_OPTION.value) + } + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFieldText + fullWidth + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption.value', + { defaultMessage: 'Value' } + )} + onChange={e => onChangeFilter([key, e.target.value], idx)} + value={value} + isInvalid={!isEmpty(key) && isEmpty(value)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="trash" + onClick={() => onRemoveFilter(idx)} + disabled={!key && filters.length === 1} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); + })} + + <EuiSpacer size="xs" /> + + <AddFilterButton + onClick={handleAddFilter} + // Disable button when user has already added all items available + isDisabled={filters.length === filterSelectOptions.length - 1} + /> + </> + ); +}; + +const AddFilterButton = ({ + onClick, + isDisabled +}: { + onClick: () => void; + isDisabled: boolean; +}) => ( + <EuiButtonEmpty + iconType="plusInCircle" + onClick={onClick} + disabled={isDisabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter', + { + defaultMessage: 'Add another filter' + } + )} + </EuiButtonEmpty> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx new file mode 100644 index 000000000000..cb2722130981 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DeleteButton } from './DeleteButton'; + +export const FlyoutFooter = ({ + onClose, + isSaving, + onDelete, + customLinkId, + isSaveButtonEnabled +}: { + onClose: () => void; + isSaving: boolean; + onDelete: () => void; + customLinkId?: string; + isSaveButtonEnabled: boolean; +}) => { + return ( + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={onClose} flush="left"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.close', + { + defaultMessage: 'Close' + } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup> + {customLinkId && ( + <EuiFlexItem> + <DeleteButton customLinkId={customLinkId} onDelete={onDelete} /> + </EuiFlexItem> + )} + <EuiFlexItem> + <EuiButton + fill + type="submit" + isLoading={isSaving} + isDisabled={!isSaveButtonEnabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.save', + { + defaultMessage: 'Save' + } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx new file mode 100644 index 000000000000..89f55a6c682c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiFieldText, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +interface InputField { + name: keyof CustomLink; + label: string; + helpText: string; + placeholder: string; + onChange: (value: string) => void; + value?: string; +} + +interface Props { + label?: string; + onChangeLabel: (label: string) => void; + url?: string; + onChangeUrl: (url: string) => void; +} + +export const LinkSection = ({ + label, + onChangeLabel, + url, + onChangeUrl +}: Props) => { + const inputFields: InputField[] = [ + { + name: 'label', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label', + { + defaultMessage: 'Label' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.helpText', + { + defaultMessage: + 'This is the label shown in the actions context menu. Keep it as short as possible.' + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.placeholder', + { + defaultMessage: 'e.g. Support tickets' + } + ), + value: label, + onChange: onChangeLabel + }, + { + name: 'url', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url', + { + defaultMessage: 'URL' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', + { + defaultMessage: + 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.', + values: { sample: '{{trace.id}}' } + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder', + { + defaultMessage: 'e.g. https://www.elastic.co/' + } + ), + value: url, + onChange: onChangeUrl + } + ]; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.action.title', + { + defaultMessage: 'Link' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="l" /> + {inputFields.map(field => { + return ( + <EuiFormRow + fullWidth + key={field.name} + label={field.label} + helpText={field.helpText} + labelAppend={ + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.required', + { + defaultMessage: 'Required' + } + )} + </EuiText> + } + > + <EuiFieldText + placeholder={field.placeholder} + name={field.name} + fullWidth + value={field.value} + onChange={e => field.onChange(e.target.value)} + aria-label={field.name} + /> + </EuiFormRow> + ); + })} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts new file mode 100644 index 000000000000..bb86a251594a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { isEmpty, pick } from 'lodash'; +import { + FilterOptions, + filterOptions + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +export type Filters = Array<[keyof FilterOptions | '', string]>; + +interface FilterSelectOption { + value: 'DEFAULT' | keyof FilterOptions; + text: string; +} + +/** + * Converts available filters from the Custom Link to Array of filters. + * e.g. + * customLink = { + * id: '1', + * label: 'foo', + * url: 'http://www.elastic.co', + * service.name: 'opbeans-java', + * transaction.type: 'request' + * } + * + * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * @param customLink + */ +export const convertFiltersToArray = (customLink?: CustomLink): Filters => { + if (customLink) { + const filters = Object.entries(pick(customLink, filterOptions)) as Filters; + if (!isEmpty(filters)) { + return filters; + } + } + return [['', '']]; +}; + +/** + * Converts array of filters into object. + * e.g. + * filters: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * + * results: { + * 'service.name': 'opbeans-java', + * 'transaction.type': 'request' + * } + * @param filters + */ +export const convertFiltersToObject = (filters: Filters) => { + const convertedFilters = Object.fromEntries( + filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + ); + if (!isEmpty(convertedFilters)) { + return convertedFilters; + } +}; + +export const DEFAULT_OPTION: FilterSelectOption = { + value: 'DEFAULT', + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption', + { defaultMessage: 'Select field...' } + ) +}; + +export const filterSelectOptions: FilterSelectOption[] = [ + DEFAULT_OPTION, + ...filterOptions.map(filter => ({ + value: filter as keyof FilterOptions, + text: filter + })) +]; + +/** + * Returns the options available, removing filters already added, but keeping the selected filter. + * + * @param filters + * @param idx + */ +export const getSelectOptions = (filters: Filters, idx: number) => { + return filterSelectOptions.filter(option => { + const indexUsedFilter = filters.findIndex( + filter => filter[0] === option.value + ); + // Filter out all items already added, besides the one selected in the current filter. + return indexUsedFilter === -1 || idx === indexUsedFilter; + }); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx new file mode 100644 index 000000000000..88358c888160 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { FiltersSection } from './FiltersSection'; +import { FlyoutFooter } from './FlyoutFooter'; +import { LinkSection } from './LinkSection'; +import { saveCustomLink } from './saveCustomLink'; +import { convertFiltersToArray, convertFiltersToObject } from './helper'; + +interface Props { + onClose: () => void; + customLinkSelected?: CustomLink; + onSave: () => void; + onDelete: () => void; +} + +export const CustomLinkFlyout = ({ + onClose, + customLinkSelected, + onSave, + onDelete +}: Props) => { + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + + const [label, setLabel] = useState(customLinkSelected?.label || ''); + const [url, setUrl] = useState(customLinkSelected?.url || ''); + const [filters, setFilters] = useState( + convertFiltersToArray(customLinkSelected) + ); + + const isFormValid = !!label && !!url; + + const onSubmit = async ( + event: + | React.FormEvent<HTMLFormElement> + | React.MouseEvent<HTMLButtonElement> + ) => { + event.preventDefault(); + setIsSaving(true); + await saveCustomLink({ + id: customLinkSelected?.id, + label, + url, + filters: convertFiltersToObject(filters), + toasts + }); + setIsSaving(false); + onSave(); + }; + + return ( + <EuiPortal> + <form onSubmit={onSubmit}> + <EuiFlyout ownFocus onClose={onClose} size="m"> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.title', + { + defaultMessage: 'Create link' + } + )} + </h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiText> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label', + { + defaultMessage: + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links and use the filter options to scope them to only appear for specific services. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. TODO: Learn more about it in the docs.' + } + )} + </p> + </EuiText> + + <EuiSpacer size="l" /> + + <LinkSection + label={label} + onChangeLabel={setLabel} + url={url} + onChangeUrl={setUrl} + /> + + <EuiSpacer size="l" /> + + <FiltersSection filters={filters} onChangeFilters={setFilters} /> + </EuiFlyoutBody> + + <FlyoutFooter + isSaveButtonEnabled={isFormValid} + onClose={onClose} + isSaving={isSaving} + onDelete={onDelete} + customLinkId={customLinkSelected?.id} + /> + </EuiFlyout> + </form> + </EuiPortal> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts new file mode 100644 index 000000000000..f255840e1d73 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveCustomLink({ + id, + label, + url, + filters, + toasts +}: { + id?: string; + label: string; + url: string; + filters?: { [key: string]: string }; + toasts: NotificationsStart['toasts']; +}) { + try { + const customLink = { + label, + url, + ...filters + }; + if (id) { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'PUT', + params: { + path: { id }, + body: customLink + } + }); + } else { + await callApmApi({ + pathname: '/api/apm/settings/custom_links', + method: 'POST', + params: { + body: customLink + } + }); + } + toasts.addSuccess({ + iconType: 'check', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.successed', + { defaultMessage: 'Link saved!' } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed', + { defaultMessage: 'Link could not be saved!' } + ), + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed.message', + { + defaultMessage: + 'Something went wrong when saving the link. Error: "{errorMessage}"', + values: { + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx new file mode 100644 index 000000000000..f7d8c4baa71e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { units, px } from '../../../../../style/variables'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { ManagedTable } from '../../../../shared/ManagedTable'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; + +interface Props { + items: CustomLink[]; + onCustomLinkSelected: (customLink: CustomLink) => void; +} + +export const CustomLinkTable = ({ + items = [], + onCustomLinkSelected +}: Props) => { + const [searchTerm, setSearchTerm] = useState(''); + + const columns = [ + { + field: 'label', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.name', + { defaultMessage: 'Name' } + ), + truncateText: true + }, + { + field: 'url', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.url', + { defaultMessage: 'URL' } + ), + truncateText: true + }, + { + width: px(160), + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.lastUpdated', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) + }, + { + width: px(units.triple), + name: '', + actions: [ + { + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel', + { defaultMessage: 'Edit' } + ), + description: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription', + { defaultMessage: 'Edit this custom link' } + ), + icon: 'pencil', + color: 'primary', + type: 'icon', + onClick: (customLink: CustomLink) => { + onCustomLinkSelected(customLink); + } + } + ] + } + ]; + + const filteredItems = items.filter(({ label, url }) => { + return ( + label.toLowerCase().includes(searchTerm) || + url.toLowerCase().includes(searchTerm) + ); + }); + + return ( + <> + <EuiSpacer size="m" /> + <EuiFieldSearch + fullWidth + onChange={e => setSearchTerm(e.target.value)} + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.searchInput.filter', + { + defaultMessage: 'Filter links by Name and URL...' + } + )} + /> + <EuiSpacer size="s" /> + <ManagedTable + noItemsMessage={ + isEmpty(items) ? ( + <LoadingStatePrompt /> + ) : ( + <NoResultFound value={searchTerm} /> + ) + } + items={filteredItems} + columns={columns} + initialPageSize={10} + initialSortField="@timestamp" + initialSortDirection="desc" + /> + </> + ); +}; + +const NoResultFound = ({ value }: { value: string }) => ( + <EuiFlexGroup justifyContent="spaceAround"> + <EuiFlexItem grow={false}> + <EuiText size="s"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.noResultFound', + { + defaultMessage: `No results for "{value}".`, + values: { value } + } + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx new file mode 100644 index 000000000000..e75004918f43 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const EmptyPrompt = ({ + onCreateCustomLinkClick +}: { + onCreateCustomLinkClick: () => void; +}) => { + return ( + <EuiEmptyPrompt + iconType="link" + iconColor="" + title={ + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptTitle', + { + defaultMessage: 'No links found.' + } + )} + </h2> + } + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptText', + { + defaultMessage: + "Let's change that! You can add custom links to the Actions context menu by the transaction details for each service. Create a helpful link to your company's support portal or open a new bug report. Learn more about it in our docs." + } + )} + </p> + </> + } + actions={<CreateCustomLinkButton onClick={onCreateCustomLinkClick} />} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx similarity index 81% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx index d7f90e091973..17ec42b3e201 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx @@ -14,8 +14,8 @@ export const Title = () => ( <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> <EuiFlexItem grow={false}> <h1> - {i18n.translate('xpack.apm.settings.customizeUI.customActions', { - defaultMessage: 'Custom actions' + {i18n.translate('xpack.apm.settings.customizeUI.customLink', { + defaultMessage: 'Custom Links' })} </h1> </EuiFlexItem> @@ -25,10 +25,10 @@ export const Title = () => ( type="iInCircle" position="top" content={i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.info', + 'xpack.apm.settings.customizeUI.customLink.info', { defaultMessage: - "These actions will be shown in the 'Actions' context menu for the trace and error detail components." + "These links will be shown in the 'Actions' context menu for the transaction detail." } )} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx new file mode 100644 index 000000000000..f02cc2be8268 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fireEvent, render, wait } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CustomLinkOverview } from '../'; +import * as hooks from '../../../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + MockApmPluginContextWrapper +} from '../../../../../../utils/testHelpers'; +import * as saveCustomLink from '../CustomLinkFlyout/saveCustomLink'; +import * as apmApi from '../../../../../../services/rest/createCallApmApi'; + +const data = [ + { + id: '1', + label: 'label 1', + url: 'url 1', + 'service.name': 'opbeans-java' + }, + { + id: '2', + label: 'label 2', + url: 'url 2', + 'transaction.type': 'request' + } +]; + +describe('CustomLink', () => { + describe('empty prompt', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + it('shows when no link is available', () => { + const component = render(<CustomLinkOverview />); + expectTextsInDocument(component, ['No links found.']); + }); + it('opens flyout when click to create new link', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('overview', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('shows a table with all custom link', () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expectTextsInDocument(component, [ + 'label 1', + 'url 1', + 'label 2', + 'url 2' + ]); + }); + + it('checks if create custom link button is available and working', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('Flyout', () => { + const refetch = jest.fn(); + let callApmApiSpy: Function; + let saveCustomLinkSpy: Function; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi'); + saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success', + refetch + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + const openFlyout = () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(component.getByText('Create custom link')); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + return component; + }; + + it('creates a custom link', async () => { + const component = openFlyout(); + const labelInput = component.getByLabelText('label'); + act(() => { + fireEvent.change(labelInput, { + target: { value: 'foo' } + }); + }); + const urlInput = component.getByLabelText('url'); + act(() => { + fireEvent.change(urlInput, { + target: { value: 'bar' } + }); + }); + await act(async () => { + await wait(() => fireEvent.submit(component.getByText('Save'))); + }); + expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); + }); + + it('deletes a custom link', async () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + const editButtons = component.getAllByLabelText('Edit'); + expect(editButtons.length).toEqual(2); + act(() => { + fireEvent.click(editButtons[0]); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + await act(async () => { + await wait(() => fireEvent.click(component.getByText('Delete'))); + }); + expect(callApmApiSpy).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + }); + + describe('Filters', () => { + const addFilterField = ( + component: ReturnType<typeof openFlyout>, + amount: number + ) => { + for (let i = 1; i <= amount; i++) { + fireEvent.click(component.getByText('Add another filter')); + } + }; + it('checks if add filter button is disabled after all elements have been added', () => { + const component = openFlyout(); + expect(component.getAllByText('service.name').length).toEqual(1); + addFilterField(component, 1); + expect(component.getAllByText('service.name').length).toEqual(2); + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + // After 4 items, the button is disabled + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + }); + it('removes items already selected', () => { + const component = openFlyout(); + + const addFieldAndCheck = ( + fieldName: string, + selectValue: string, + addNewFilter: boolean, + optionsExpected: string[] + ) => { + if (addNewFilter) { + addFilterField(component, 1); + } + const field = component.getByLabelText( + fieldName + ) as HTMLSelectElement; + const optionsAvailable = Object.values(field) + .map(option => (option as HTMLOptionElement).text) + .filter(option => option); + + act(() => { + fireEvent.change(field, { + target: { value: selectValue } + }); + }); + expect(field.value).toEqual(selectValue); + expect(optionsAvailable).toEqual(optionsExpected); + }; + + addFieldAndCheck('filter-0', 'transaction.name', false, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name' + ]); + + addFieldAndCheck('filter-1', 'service.name', true, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-2', 'transaction.type', true, [ + 'Select field...', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-3', 'service.environment', true, [ + 'Select field...', + 'service.environment' + ]); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx new file mode 100644 index 000000000000..bc1882c8c278 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { CustomLinkFlyout } from './CustomLinkFlyout'; +import { CustomLinkTable } from './CustomLinkTable'; +import { EmptyPrompt } from './EmptyPrompt'; +import { Title } from './Title'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const CustomLinkOverview = () => { + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [customLinkSelected, setCustomLinkSelected] = useState< + CustomLink | undefined + >(); + + const { data: customLinks, status, refetch } = useFetcher( + callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }), + [] + ); + + useEffect(() => { + if (customLinkSelected) { + setIsFlyoutOpen(true); + } + }, [customLinkSelected]); + + const onCloseFlyout = () => { + setCustomLinkSelected(undefined); + setIsFlyoutOpen(false); + }; + + const onCreateCustomLinkClick = () => { + setIsFlyoutOpen(true); + }; + + const showEmptyPrompt = + status === FETCH_STATUS.SUCCESS && isEmpty(customLinks); + + return ( + <> + {isFlyoutOpen && ( + <CustomLinkFlyout + onClose={onCloseFlyout} + customLinkSelected={customLinkSelected} + onSave={() => { + onCloseFlyout(); + refetch(); + }} + onDelete={() => { + onCloseFlyout(); + refetch(); + }} + /> + )} + <EuiPanel> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <Title /> + </EuiFlexItem> + {!showEmptyPrompt && ( + <EuiFlexItem> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <CreateCustomLinkButton onClick={onCreateCustomLinkClick} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + {showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + )} + </EuiPanel> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index 17a4b2f84767..1cd1298fdd54 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CustomActionsOverview } from './CustomActionsOverview'; +import { CustomLinkOverview } from './CustomLink'; export const CustomizeUI = () => { return ( @@ -20,7 +20,7 @@ export const CustomizeUI = () => { </h1> </EuiTitle> <EuiSpacer size="l" /> - <CustomActionsOverview /> + <CustomLinkOverview /> </> ); }; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts b/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts deleted file mode 100644 index b28b295d8189..000000000000 --- a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { useApmPluginContext } from './useApmPluginContext'; - -export function useCallApmApi() { - const { http } = useApmPluginContext().core; - - return useMemo(() => { - return createCallApmApi(http); - }, [http]); -} diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index d2202fff996b..c2530d6982c3 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext'; -import { APMClient } from '../services/rest/createCallApmApi'; -import { useCallApmApi } from './useCallApmApi'; +import { APMClient, callApmApi } from '../services/rest/createCallApmApi'; import { useApmPluginContext } from './useApmPluginContext'; import { useLoadingIndicator } from './useLoadingIndicator'; @@ -46,8 +45,6 @@ export function useFetcher<TReturn>( const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); - const callApmApi = useCallApmApi(); - const { dispatchStatus } = useContext(LoadingIndicatorContext); const [result, setResult] = useState<Result<InferResponseType<TReturn>>>({ data: undefined, diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0054f963ba8f..0103dd72a3fe 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -39,6 +39,7 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -104,6 +105,7 @@ export class ApmPlugin public start(core: CoreStart) { const i18nCore = core.i18n; const plugins = this.setupPlugins; + createCallApmApi(core.http); // Once we're actually an NP plugin we'll get the config from the // initializerContext like: @@ -157,7 +159,7 @@ export class ApmPlugin ); // create static index pattern and store as saved object. Not needed by APM UI but for legacy reasons in Discover, Dashboard etc. - createStaticIndexPattern(core.http).catch(e => { + createStaticIndexPattern().catch(e => { // eslint-disable-next-line no-console console.log('Error fetching static index pattern', e); }); diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts index 9cca9469bba0..2d4fd8300317 100644 --- a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts +++ b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts @@ -5,7 +5,7 @@ */ import * as callApiExports from '../rest/callApi'; -import { createCallApmApi, APMClient } from '../rest/createCallApmApi'; +import { createCallApmApi, callApmApi } from '../rest/createCallApmApi'; import { HttpSetup } from 'kibana/public'; const callApi = jest @@ -13,9 +13,8 @@ const callApi = jest .mockImplementation(() => Promise.resolve(null)); describe('callApmApi', () => { - let callApmApi: APMClient; beforeEach(() => { - callApmApi = createCallApmApi({} as HttpSetup); + createCallApmApi({} as HttpSetup); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts index 220320216788..2fffb40d353f 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts @@ -19,8 +19,14 @@ export type APMClientOptions = Omit<FetchOptions, 'query' | 'body'> & { }; }; -export const createCallApmApi = (http: HttpSetup) => - ((options: APMClientOptions) => { +export let callApmApi: APMClient = () => { + throw new Error( + 'callApmApi has to be initialized before used. Call createCallApmApi first.' + ); +}; + +export function createCallApmApi(http: HttpSetup) { + callApmApi = ((options: APMClientOptions) => { const { pathname, params = {}, ...opts } = options; const path = (params.path || {}) as Record<string, any>; @@ -36,3 +42,4 @@ export const createCallApmApi = (http: HttpSetup) => query: params.query }); }) as APMClient; +} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts index 8e1234dd55e6..1efcc98bbbd6 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'kibana/public'; -import { createCallApmApi } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; -export const createStaticIndexPattern = async (http: HttpSetup) => { - const callApmApi = createCallApmApi(http); +export const createStaticIndexPattern = async () => { return await callApmApi({ method: 'POST', pathname: '/api/apm/index_pattern/static' diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts index 5e64d7e1ce71..1c618098b36e 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts @@ -16,7 +16,7 @@ import { } from '../../../../../../plugins/apm/common/ml_job_constants'; import { callApi } from './callApi'; import { ESFilter } from '../../../../../../plugins/apm/typings/elasticsearch'; -import { createCallApmApi, APMClient } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; interface MlResponseItem { id: string; @@ -36,7 +36,6 @@ interface StartedMLJobApiResponse { } async function getTransactionIndices(http: HttpSetup) { - const callApmApi: APMClient = createCallApmApi(http); const indices = await callApmApi({ method: 'GET', pathname: `/api/apm/settings/apm-indices` diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index dec2257746e5..4ee45f7b3330 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -29,6 +29,7 @@ import { ApmPluginContextValue } from '../context/ApmPluginContext'; import { ConfigSchema } from '../new-platform/plugin'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -118,6 +119,7 @@ interface MockSetup { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; }; } @@ -162,7 +164,8 @@ export async function inspectSearchParams( 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; @@ -195,7 +198,8 @@ const mockCore = { }, notifications: { toasts: { - addWarning: () => {} + addWarning: () => {}, + addDanger: () => {} } } }; @@ -222,6 +226,9 @@ export function MockApmPluginContextWrapper({ children?: ReactNode; value?: ApmPluginContextValue; }) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } return ( <ApmPluginContext.Provider value={{ diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts index 3ac47004279b..ef1934235807 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts @@ -53,7 +53,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'apm-*', 'apm_oss.transactionIndices': 'apm-*', 'apm_oss.metricsIndices': 'apm-*', - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts new file mode 100644 index 000000000000..0a0da332e73a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, Logger } from 'src/core/server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; + +export type Mappings = + | { + dynamic?: boolean; + properties: Record<string, Mappings>; + } + | { + type: string; + ignore_above?: number; + scaling_factor?: number; + ignore_malformed?: boolean; + coerce?: boolean; + }; + +export async function createOrUpdateIndex({ + index, + mappings, + esClient, + logger +}: { + index: string; + mappings: Mappings; + esClient: IClusterClient; + logger: Logger; +}) { + try { + const { callAsInternalUser } = esClient; + const indexExists = await callAsInternalUser('indices.exists', { index }); + const result = indexExists + ? await updateExistingIndex({ + index, + callAsInternalUser, + mappings + }) + : await createNewIndex({ + index, + callAsInternalUser, + mappings + }); + + if (!result.acknowledged) { + const resultError = + result && result.error && JSON.stringify(result.error); + throw new Error(resultError); + } + } catch (e) { + logger.error(`Could not create APM index: '${index}'. Error: ${e.message}`); + } +} + +function createNewIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.create', { + index, + body: { + // auto_expand_replicas: Allows cluster to not have replicas for this index + settings: { 'index.auto_expand_replicas': '0-1' }, + mappings + } + }); +} + +function updateExistingIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.putMapping', { + index, + body: mappings + }); +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index 8cfb7e7edb4c..bc03138e0c24 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -5,8 +5,11 @@ */ import { IClusterClient, Logger } from 'src/core/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; export async function createApmAgentConfigurationIndex({ @@ -18,87 +21,54 @@ export async function createApmAgentConfigurationIndex({ config: APMConfig; logger: Logger; }) { - try { - const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; - const { callAsInternalUser } = esClient; - const indexExists = await callAsInternalUser('indices.exists', { index }); - const result = indexExists - ? await updateExistingIndex(index, callAsInternalUser) - : await createNewIndex(index, callAsInternalUser); - - if (!result.acknowledged) { - const resultError = - result && result.error && JSON.stringify(result.error); - throw new Error( - `Unable to create APM Agent Configuration index '${index}': ${resultError}` - ); - } - } catch (e) { - logger.error(`Could not create APM Agent configuration: ${e.message}`); - } + const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); } -function createNewIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.create', { - index, - body: { - settings: { 'index.auto_expand_replicas': '0-1' }, - mappings: { properties: mappingProperties } - } - }); -} - -// Necessary for migration reasons -// Added in 7.5: `capture_body`, `transaction_max_spans`, `applied_by_agent`, `agent_name` and `etag` -function updateExistingIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.putMapping', { - index, - body: { properties: mappingProperties } - }); -} - -const mappingProperties = { - '@timestamp': { - type: 'date' - }, - service: { - properties: { - name: { - type: 'keyword', - ignore_above: 1024 - }, - environment: { - type: 'keyword', - ignore_above: 1024 +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + service: { + properties: { + name: { + type: 'keyword', + ignore_above: 1024 + }, + environment: { + type: 'keyword', + ignore_above: 1024 + } } - } - }, - settings: { - properties: { - transaction_sample_rate: { - type: 'scaled_float', - scaling_factor: 1000, - ignore_malformed: true, - coerce: false - }, - capture_body: { - type: 'keyword', - ignore_above: 1024 - }, - transaction_max_spans: { - type: 'short' + }, + settings: { + properties: { + transaction_sample_rate: { + type: 'scaled_float', + scaling_factor: 1000, + ignore_malformed: true, + coerce: false + }, + capture_body: { + type: 'keyword', + ignore_above: 1024 + }, + transaction_max_spans: { + type: 'short' + } } + }, + applied_by_agent: { + type: 'boolean' + }, + agent_name: { + type: 'keyword', + ignore_above: 1024 + }, + etag: { + type: 'keyword', + ignore_above: 1024 } - }, - applied_by_agent: { - type: 'boolean' - }, - agent_name: { - type: 'keyword', - ignore_above: 1024 - }, - etag: { - type: 'keyword', - ignore_above: 1024 } }; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index 00493e53f06d..f338ee058842 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -25,6 +25,7 @@ export interface ApmIndicesConfig { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; } export type ApmIndicesName = keyof ApmIndicesConfig; @@ -52,7 +53,8 @@ export function getApmIndicesConfig(config: APMConfig): ApmIndicesConfig { 'apm_oss.transactionIndices': config['apm_oss.transactionIndices'], 'apm_oss.metricsIndices': config['apm_oss.metricsIndices'], // system indices, not configurable - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }; } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap new file mode 100644 index 000000000000..b3819ace40d6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`List Custom Links fetches all custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; + +exports[`List Custom Links filters custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.name", + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "transaction.name": "bar", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "transaction.name", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts new file mode 100644 index 000000000000..624f01c64932 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createOrUpdateCustomLink } from '../create_or_update_custom_link'; +import { CustomLink } from '../custom_link_types'; +import { Setup } from '../../../helpers/setup_request'; +import { mockNow } from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; + +describe('Create or Update Custom link', () => { + const internalClientIndexMock = jest.fn(); + const mockedSetup = ({ + internalClient: { + index: internalClientIndexMock + }, + indices: { + apmCustomLinkIndex: 'apmCustomLinkIndex' + } + } as unknown) as Setup; + + const customLink = ({ + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } as unknown) as CustomLink; + afterEach(() => { + internalClientIndexMock.mockClear(); + }); + + beforeAll(() => { + mockNow(1570737000000); + }); + + it('creates a new custom link', () => { + createOrUpdateCustomLink({ customLink, setup: mockedSetup }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); + it('update a new custom link', () => { + createOrUpdateCustomLink({ + customLinkId: 'bar', + customLink, + setup: mockedSetup + }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + id: 'bar', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts new file mode 100644 index 000000000000..5466225dc321 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { listCustomLinks } from '../list_custom_links'; +import { + inspectSearchParams, + SearchParamsMock +} from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { Setup } from '../../../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_NAME +} from '../../../../../common/elasticsearch_fieldnames'; + +describe('List Custom Links', () => { + let mock: SearchParamsMock; + + it('fetches all custom links', async () => { + mock = await inspectSearchParams(setup => + listCustomLinks({ + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + + it('filters custom links', async () => { + const filters = { + [SERVICE_NAME]: 'foo', + [TRANSACTION_NAME]: 'bar' + }; + mock = await inspectSearchParams(setup => + listCustomLinks({ + filters, + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts new file mode 100644 index 000000000000..cdb3cff61603 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, Logger } from 'src/core/server'; +import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; +import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; + +export const createApmCustomLinkIndex = async ({ + esClient, + config, + logger +}: { + esClient: IClusterClient; + config: APMConfig; + logger: Logger; +}) => { + const index = getApmIndicesConfig(config).apmCustomLinkIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); +}; + +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + label: { + type: 'text' + }, + url: { + type: 'keyword' + }, + service: { + properties: { + name: { + type: 'keyword' + }, + environment: { + type: 'keyword' + } + } + }, + transaction: { + properties: { + name: { + type: 'keyword' + }, + type: { + type: 'keyword' + } + } + } + } +}; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts new file mode 100644 index 000000000000..809fe2050a07 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; +import { filterOptions } from '../../../routes/settings/custom_link'; +import { APMIndexDocumentParams } from '../../helpers/es_client'; +import { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; + +export async function createOrUpdateCustomLink({ + customLinkId, + customLink, + setup +}: { + customLinkId?: string; + customLink: Omit<CustomLink, '@timestamp'>; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params: APMIndexDocumentParams<CustomLink> = { + refresh: true, + index: indices.apmCustomLinkIndex, + body: { + '@timestamp': Date.now(), + label: customLink.label, + url: customLink.url, + ...pick(customLink, filterOptions) + } + }; + + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (customLinkId) { + params.id = customLinkId; + } + + return internalClient.index(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts new file mode 100644 index 000000000000..60b97712713a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export type CustomLink = { + id?: string; + '@timestamp': number; + label: string; + url: string; +} & FilterOptions; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts new file mode 100644 index 000000000000..2f3ea0940cb2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; + +export async function deleteCustomLink({ + customLinkId, + setup +}: { + customLinkId: string; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params = { + refresh: 'wait_for', + index: indices.apmCustomLinkIndex, + id: customLinkId + }; + + return internalClient.delete(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts new file mode 100644 index 000000000000..e6052da73b0d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export async function listCustomLinks({ + setup, + filters = {} +}: { + setup: Setup; + filters?: FilterOptions; +}) { + const { internalClient, indices } = setup; + + const esFilters = Object.entries(filters).map(([key, value]) => { + return { + bool: { + minimum_should_match: 1, + should: [ + { term: { [key]: value } }, + { bool: { must_not: [{ exists: { field: key } }] } } + ] + } + }; + }); + + const params = { + index: indices.apmCustomLinkIndex, + size: 500, + body: { + query: { + bool: { + filter: esFilters + } + } + } + }; + const resp = await internalClient.search<CustomLink>(params); + return resp.hits.hits.map(item => ({ + id: item._id, + ...item._source + })); +} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts index c4a0be0f48c1..02bf60d3605b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts @@ -28,7 +28,8 @@ function getSetup() { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts index 9ab31be9f721..5e443b92aa91 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts @@ -17,7 +17,8 @@ const mockIndices = { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }; function getMockSetup(esResponse: any) { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index cc8fabe33e63..7a3277965ef8 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -42,7 +42,8 @@ describe('getAnomalySeries', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts index 1970e39a2752..a87a277eb0c0 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts @@ -41,7 +41,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 773f0d4e6fac..db14730f802a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; +import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; @@ -66,6 +67,12 @@ export class APMPlugin implements Plugin<APMPluginContract> { config: currentConfig, logger }); + // create custom action index without blocking setup lifecycle + createApmCustomLinkIndex({ + esClient: core.elasticsearch.dataClient, + config: currentConfig, + logger + }); plugins.home.tutorials.registerTutorial( tutorialProvider({ diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 21392edbb2c4..34f0536a90b4 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -59,6 +59,12 @@ import { import { createApi } from './create_api'; import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; import { indicesPrivilegesRoute } from './security'; +import { + createCustomLinkRoute, + updateCustomLinkRoute, + deleteCustomLinkRoute, + listCustomLinksRoute +} from './settings/custom_link'; const createApmApi = () => { const api = createApi() @@ -126,7 +132,13 @@ const createApmApi = () => { .add(serviceMapServiceNodeRoute) // security - .add(indicesPrivilegesRoute); + .add(indicesPrivilegesRoute) + + // Custom links + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute) + .add(listCustomLinksRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts new file mode 100644 index 000000000000..5988d7f85b18 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_NAME, + TRANSACTION_TYPE +} from '../../../common/elasticsearch_fieldnames'; +import { createRoute } from '../create_route'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; +import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; +import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; + +const FilterOptionsRt = t.partial({ + [SERVICE_NAME]: t.string, + [SERVICE_ENVIRONMENT]: t.string, + [TRANSACTION_NAME]: t.string, + [TRANSACTION_TYPE]: t.string +}); + +export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; + +export const filterOptions: Array<keyof FilterOptions> = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +]; + +export const listCustomLinksRoute = createRoute(core => ({ + path: '/api/apm/settings/custom_links', + params: { + query: FilterOptionsRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + return await listCustomLinks({ setup, filters: params.query }); + } +})); + +const payload = t.intersection([ + t.type({ + label: t.string, + url: t.string + }), + FilterOptionsRt +]); + +export const createCustomLinkRoute = createRoute(() => ({ + method: 'POST', + path: '/api/apm/settings/custom_links', + params: { + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ customLink, setup }); + return res; + } +})); + +export const updateCustomLinkRoute = createRoute(() => ({ + method: 'PUT', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }), + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ + customLinkId: id, + customLink, + setup + }); + return res; + } +})); + +export const deleteCustomLinkRoute = createRoute(() => ({ + method: 'DELETE', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }) + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const res = await deleteCustomLink({ + customLinkId: id, + setup + }); + return res; + } +}));