[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
This commit is contained in:
Cauê Marcondes 2020-03-06 10:06:16 +00:00 committed by GitHub
parent 651d0a9739
commit 2817d6e3a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2009 additions and 516 deletions

View file

@ -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],
},
},

View file

@ -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(() => {

View file

@ -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 (
<EuiButtonEmpty
@ -31,7 +29,7 @@ export function DeleteButton({ onDeleted, selectedConfig }: Props) {
iconSide="right"
onClick={async () => {
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']
) {

View file

@ -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({
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceForm.title',
'xpack.apm.settings.agentConf.flyOut.serviceSection.title',
{ defaultMessage: 'Service' }
)}
</h3>
@ -93,13 +93,13 @@ export function ServiceForm({
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceForm.serviceNameSelectLabel',
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectLabel',
{ defaultMessage: 'Name' }
)}
helpText={
!isReadOnly &&
i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceForm.serviceNameSelectHelpText',
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectHelpText',
{ defaultMessage: 'Choose the service you want to configure.' }
)
}
@ -124,13 +124,13 @@ export function ServiceForm({
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceForm.serviceEnvironmentSelectLabel',
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectLabel',
{ defaultMessage: 'Environment' }
)}
helpText={
!isReadOnly &&
i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceForm.serviceEnvironmentSelectHelpText',
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectHelpText',
{
defaultMessage:
'Only a single environment per configuration is supported.'

View file

@ -22,11 +22,10 @@ import {
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isRight } from 'fp-ts/lib/Either';
import { useCallApmApi } from '../../../../../hooks/useCallApmApi';
import { transactionSampleRateRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_sample_rate_rt';
import { Config } from '../index';
import { SettingsSection } from './SettingsSection';
import { ServiceForm } from '../../../../shared/ServiceForm';
import { ServiceSection } from './ServiceSection';
import { DeleteButton } from './DeleteButton';
import { transactionMaxSpansRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_max_spans_rt';
import { useFetcher } from '../../../../../hooks/useFetcher';
@ -58,8 +57,6 @@ export function AddEditFlyout({
const { toasts } = useApmPluginContext().core.notifications;
const [isSaving, setIsSaving] = useState(false);
const callApmApiFromHook = useCallApmApi();
// get a telemetry UI event tracker
const trackApmEvent = useUiTracker({ app: 'apm' });
@ -129,7 +126,6 @@ export function AddEditFlyout({
setIsSaving(true);
await saveConfig({
callApmApi: callApmApiFromHook,
serviceName,
environment,
sampleRate,
@ -181,7 +177,7 @@ export function AddEditFlyout({
}
}}
>
<ServiceForm
<ServiceSection
isReadOnly={Boolean(selectedConfig)}
//
// environment

View file

@ -6,7 +6,7 @@
import { i18n } from '@kbn/i18n';
import { NotificationsStart } from 'kibana/public';
import { APMClient } from '../../../../../services/rest/createCallApmApi';
import { callApmApi } from '../../../../../services/rest/createCallApmApi';
import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name';
import {
getOptionLabel,
@ -21,7 +21,6 @@ interface Settings {
}
export async function saveConfig({
callApmApi,
serviceName,
environment,
sampleRate,
@ -32,7 +31,6 @@ export async function saveConfig({
toasts,
trackApmEvent
}: {
callApmApi: APMClient;
serviceName: string;
environment: string;
sampleRate: string;

View file

@ -20,8 +20,7 @@ import {
EuiButtonEmpty
} from '@elastic/eui';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useCallApmApi } from '../../../../hooks/useCallApmApi';
import { APMClient } from '../../../../services/rest/createCallApmApi';
import { callApmApi } from '../../../../services/rest/createCallApmApi';
import { clearCache } from '../../../../services/rest/callApi';
import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
@ -68,10 +67,8 @@ const APM_INDEX_LABELS = [
];
async function saveApmIndices({
callApmApi,
apmIndices
}: {
callApmApi: APMClient;
apmIndices: Record<string, string>;
}) {
await callApmApi({
@ -94,11 +91,11 @@ export function ApmIndices() {
const [apmIndices, setApmIndices] = useState<Record<string, string>>({});
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',

View file

@ -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 (
<>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.title',
{ defaultMessage: 'Action' }
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.label',
{ defaultMessage: 'Label' }
)}
helpText={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.label.helpText',
{ defaultMessage: 'Labels can be a maximum of 128 characters' }
)}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.label.placeHolder',
{ defaultMessage: 'e.g. Support tickets' }
)}
value={label}
onChange={e => {
onLabelChange(e.target.value);
}}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.url',
{ defaultMessage: 'URL' }
)}
helpText={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.url.helpText',
{
defaultMessage:
'You can use relative paths by prefixing with e.g. /dashboards'
}
)}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.url.placeHolder',
{ defaultMessage: 'e.g. https://www.elastic.co/' }
)}
value={url}
onChange={e => {
onURLChange(e.target.value);
}}
/>
</EuiFormRow>
</>
);
};

View file

@ -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 (
<EuiPortal>
<EuiFlyout ownFocus onClose={onClose} size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2>
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.title',
{
defaultMessage: 'Create custom action'
}
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
<p>
{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."
}
)}
</p>
</EuiText>
<EuiSpacer size="l" />
<ServiceForm
isReadOnly={false}
serviceName={serviceName}
onServiceNameChange={setServiceName}
environment={environment}
onEnvironmentChange={setEnvironment}
/>
<EuiSpacer size="l" />
<SettingsSection
label={label}
onLabelChange={setLabel}
url={url}
onURLChange={setURL}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.close',
{
defaultMessage: 'Close'
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
// TODO: onClick={closeFlyout}
fill
>
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.flyout.save',
{
defaultMessage: 'Save'
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -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 (
<EuiEmptyPrompt
iconType="boxesHorizontal"
iconColor=""
title={
<h2>
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.emptyPromptTitle',
{
defaultMessage: 'No actions found.'
}
)}
</h2>
}
body={
<>
<p>
{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"
}
)}
</p>
</>
}
actions={
<EuiButton color="primary" fill onClick={onCreateCustomActionClick}>
{i18n.translate(
'xpack.apm.settings.customizeUI.customActions.createCustomAction',
{ defaultMessage: 'Create custom action' }
)}
</EuiButton>
}
/>
);
};

View file

@ -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(<CustomActionsOverview />);
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(<CustomActionsOverview />);
expect(queryByText('Service')).not.toBeInTheDocument();
fireEvent.click(getByText('Create custom action'));
expect(queryByText('Service')).toBeInTheDocument();
});
});
});

View file

@ -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 (
<>
<EuiPanel>
<Title />
<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>
</>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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} />}
/>
);
};

View file

@ -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."
}
)}
/>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

@ -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={{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

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

View file

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