[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:
parent
651d0a9739
commit
2817d6e3a8
|
@ -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],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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']
|
||||
) {
|
||||
|
|
|
@ -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.'
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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' }
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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."
|
||||
}
|
||||
)}
|
||||
/>
|
|
@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
14
x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts
vendored
Normal file
14
x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts
vendored
Normal 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;
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
}));
|
||||
}
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
117
x-pack/plugins/apm/server/routes/settings/custom_link.ts
Normal file
117
x-pack/plugins/apm/server/routes/settings/custom_link.ts
Normal 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;
|
||||
}
|
||||
}));
|
Loading…
Reference in a new issue