[APM] Agent remote configuration (#39555)

* [APM] Agent remote configuration UI
  - Creates apmCm index mapping on plugin initialization.
  - Links from home page to settings page
This commit is contained in:
Oliver Gupte 2019-07-02 12:18:58 -07:00 committed by GitHub
parent 2eeea97fa8
commit 0e8b722a25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1762 additions and 44 deletions

View file

@ -38,6 +38,7 @@ export default function apmOss(kibana) {
spanIndices: Joi.string().default('apm-*'),
metricsIndices: Joi.string().default('apm-*'),
onboardingIndices: Joi.string().default('apm-*'),
cmIndex: Joi.string().default('.apm-cm')
}).default();
},
@ -48,7 +49,8 @@ export default function apmOss(kibana) {
'transactionIndices',
'spanIndices',
'metricsIndices',
'onboardingIndices'
'onboardingIndices',
'cmIndex'
].map(type => server.config().get(`apm_oss.${type}`))));
}
});

View file

@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiButtonEmpty
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ApmHeader } from '../../shared/ApmHeader';
@ -12,6 +17,7 @@ import { HistoryTabs, IHistoryTab } from '../../shared/HistoryTabs';
import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink';
import { ServiceOverview } from '../ServiceOverview';
import { TraceOverview } from '../TraceOverview';
import { APMLink } from '../../shared/Links/APMLink';
const homeTabs: IHistoryTab[] = [
{
@ -32,6 +38,10 @@ const homeTabs: IHistoryTab[] = [
}
];
const SETTINGS_LINK_LABEL = i18n.translate('xpack.apm.settingsLinkLabel', {
defaultMessage: 'Settings'
});
export function Home() {
return (
<div>
@ -42,6 +52,13 @@ export function Home() {
<h1>APM</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<APMLink path="/settings">
<EuiButtonEmpty size="s" color="primary" iconType="gear">
{SETTINGS_LINK_LABEL}
</EuiButtonEmpty>
</APMLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SetupInstructionsLink />
</EuiFlexItem>

View file

@ -17,6 +17,23 @@ exports[`Home component should render 1`] = `
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<APMLink
path="/settings"
>
<EuiButtonEmpty
color="primary"
iconSide="left"
iconType="gear"
size="s"
type="button"
>
Settings
</EuiButtonEmpty>
</APMLink>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>

View file

@ -14,6 +14,7 @@ import { TransactionDetails } from '../../TransactionDetails';
import { Home } from '../Home';
import { BreadcrumbRoute } from '../ProvideBreadcrumbs';
import { RouteName } from './route_names';
import { SettingsList } from '../../Settings/SettingsList';
interface RouteParams {
serviceName: string;
@ -47,6 +48,15 @@ export const routes: BreadcrumbRoute[] = [
}),
name: RouteName.SERVICES
},
{
exact: true,
path: '/settings',
component: SettingsList,
breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', {
defaultMessage: 'Settings'
}),
name: RouteName.SETTINGS
},
{
exact: true,
path: '/traces',

View file

@ -14,5 +14,6 @@ export enum RouteName {
ERROR = 'error',
METRICS = 'metrics',
TRANSACTION_TYPE = 'transaction_type',
TRANSACTION_NAME = 'transaction_name'
TRANSACTION_NAME = 'transaction_name',
SETTINGS = 'settings'
}

View file

@ -0,0 +1,344 @@
/*
* 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,
EuiTitle,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutFooter,
EuiButton,
EuiButtonEmpty,
EuiCallOut
} from '@elastic/eui';
import React, { useState } from 'react';
import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { AddSettingFlyoutBody } from './AddSettingFlyoutBody';
import { Config } from '../SettingsList';
import { useFetcher } from '../../../../hooks/useFetcher';
import {
loadCMServices,
loadCMEnvironments,
deleteCMConfiguration,
updateCMConfiguration,
createCMConfiguration
} from '../../../../services/rest/apm/settings';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
interface Props {
onClose: () => void;
onSubmit: () => void;
isOpen: boolean;
selectedConfig: Config | null;
}
export function AddSettingsFlyout({
onClose,
isOpen,
onSubmit,
selectedConfig
}: Props) {
if (!isOpen) {
return null;
}
const [environment, setEnvironment] = useState<string | undefined>(
selectedConfig
? selectedConfig.service.environment || ENVIRONMENT_NOT_DEFINED
: undefined
);
const [serviceName, setServiceName] = useState<string | undefined>(
selectedConfig ? selectedConfig.service.name : undefined
);
const [sampleRate, setSampleRate] = useState<number>(
selectedConfig ? parseFloat(selectedConfig.settings.sample_rate) : NaN
);
const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher<
string[]
>(async () => (await loadCMServices()).sort(), [], {
preservePreviousResponse: false
});
const { data: environments = [], status: environmentStatus } = useFetcher<
Array<{ name: string; available: boolean }>
>(
() => {
if (serviceName) {
return loadCMEnvironments({ serviceName });
}
},
[serviceName],
{ preservePreviousResponse: false }
);
const isSelectedEnvironmentValid = environments.some(
env =>
env.name === environment && (Boolean(selectedConfig) || env.available)
);
const isSampleRateValid = sampleRate >= 0 && sampleRate <= 1;
return (
<EuiPortal>
<EuiFlyout size="s" onClose={onClose} ownFocus={true}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
{selectedConfig ? (
<h2>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.editConfigTitle',
{
defaultMessage: 'Edit configuration'
}
)}
</h2>
) : (
<h2>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.createConfigTitle',
{
defaultMessage: 'Create configuration'
}
)}
</h2>
)}
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.cm.flyOut.betaCallOutTitle',
{
defaultMessage: 'APM Agent Configuration (BETA)'
}
)}
iconType="iInCircle"
color="warning"
>
{i18n.translate('xpack.apm.settings.cm.flyOut.betaCallOutText', {
defaultMessage:
'Please note only sample rate configuration is supported in this first version. We will extend support for central configuration in future releases. Please be aware of bugs.'
})}
</EuiCallOut>
<EuiHorizontalRule margin="m" />
<AddSettingFlyoutBody
selectedConfig={selectedConfig}
onDelete={async () => {
if (selectedConfig) {
await deleteConfig(selectedConfig);
}
onSubmit();
}}
environment={environment}
setEnvironment={setEnvironment}
serviceName={serviceName}
setServiceName={setServiceName}
sampleRate={sampleRate}
setSampleRate={setSampleRate}
serviceNames={serviceNames}
serviceNamesStatus={serviceNamesStatus}
environments={environments}
environmentStatus={environmentStatus}
isSampleRateValid={isSampleRateValid}
isSelectedEnvironmentValid={isSelectedEnvironmentValid}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.cancelButtonLabel',
{
defaultMessage: 'Cancel'
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
isDisabled={
!(
(selectedConfig && isSampleRateValid) ||
(!selectedConfig &&
serviceName &&
environment &&
isSelectedEnvironmentValid &&
isSampleRateValid)
)
}
onClick={async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
await saveConfig({
environment,
serviceName,
sampleRate,
configurationId: selectedConfig
? selectedConfig.id
: undefined
});
onSubmit();
}}
>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.saveConfigurationButtonLabel',
{
defaultMessage: 'Save configuration'
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
}
async function deleteConfig(selectedConfig: Config) {
try {
await deleteCMConfiguration(selectedConfig.id);
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.cm.deleteConfigSucceededTitle',
{
defaultMessage: 'Configuration was deleted'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.deleteConfigSucceededText"
defaultMessage="You have successfully deleted a configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${selectedConfig.service.name}"`
}}
/>
)
});
} catch (error) {
toastNotifications.addDanger({
title: i18n.translate('xpack.apm.settings.cm.deleteConfigFailedTitle', {
defaultMessage: 'Configuration could not be deleted'
}),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.deleteConfigFailedText"
defaultMessage="Something went wrong when deleting a configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${selectedConfig.service.name}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
}
}
async function saveConfig({
sampleRate,
serviceName,
environment,
configurationId
}: {
sampleRate: number;
serviceName: string | undefined;
environment: string | undefined;
configurationId?: string;
}) {
try {
if (isNaN(sampleRate) || !serviceName) {
throw new Error('Missing arguments');
}
const configuration = {
settings: {
sample_rate: sampleRate.toString(10)
},
service: {
name: serviceName,
environment:
environment === ENVIRONMENT_NOT_DEFINED ? undefined : environment
}
};
if (configurationId) {
await updateCMConfiguration(configurationId, configuration);
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.cm.editConfigSucceededTitle',
{
defaultMessage: 'Configuration edited'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.editConfigSucceededText"
defaultMessage="You have successfully edited the configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${serviceName}"`
}}
/>
)
});
} else {
await createCMConfiguration(configuration);
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.cm.createConfigSucceededTitle',
{
defaultMessage: 'Configuration created!'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.createConfigSucceededText"
defaultMessage="You have successfully created a configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${serviceName}"`
}}
/>
)
});
}
} catch (error) {
if (configurationId) {
toastNotifications.addDanger({
title: i18n.translate('xpack.apm.settings.cm.editConfigFailedTitle', {
defaultMessage: 'Configuration could not be edited'
}),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.editConfigFailedText"
defaultMessage="Something went wrong when editing the configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${serviceName}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
} else {
toastNotifications.addDanger({
title: i18n.translate('xpack.apm.settings.cm.createConfigFailedTitle', {
defaultMessage: 'Configuration could not be created'
}),
text: (
<FormattedMessage
id="xpack.apm.settings.cm.createConfigFailedText"
defaultMessage="Something went wrong when creating a configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${serviceName}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
}
}
}

View file

@ -0,0 +1,270 @@
/*
* 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 {
EuiSelect,
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
EuiTitle,
EuiSpacer,
EuiHorizontalRule,
EuiText
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FETCH_STATUS } from '../../../../hooks/useFetcher';
import { Config } from '../SettingsList';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
export function AddSettingFlyoutBody({
selectedConfig,
onDelete,
environment,
setEnvironment,
serviceName,
setServiceName,
sampleRate,
setSampleRate,
serviceNames,
serviceNamesStatus,
environments,
environmentStatus,
isSampleRateValid,
isSelectedEnvironmentValid
}: {
selectedConfig: Config | null;
onDelete: () => void;
environment?: string;
setEnvironment: React.Dispatch<React.SetStateAction<string | undefined>>;
serviceName?: string;
setServiceName: React.Dispatch<React.SetStateAction<string | undefined>>;
sampleRate: number;
setSampleRate: React.Dispatch<React.SetStateAction<number>>;
serviceNames: string[];
serviceNamesStatus?: FETCH_STATUS;
environments: Array<{
name: string;
available: boolean;
}>;
environmentStatus?: FETCH_STATUS;
isSampleRateValid: boolean;
isSelectedEnvironmentValid: boolean;
}) {
const environmentOptions = environments.map(({ name, available }) => ({
disabled: !available,
text:
name === ENVIRONMENT_NOT_DEFINED
? i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceEnvironmentNotSetOptionLabel',
{
defaultMessage: 'Not set'
}
)
: name,
value: name
}));
return (
<EuiForm>
<form>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceSectionTitle',
{
defaultMessage: 'Service'
}
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceNameSelectLabel',
{
defaultMessage: 'Name'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceNameSelectHelpText',
{
defaultMessage: 'Choose the service you want to configure.'
}
)}
>
<EuiSelect
isLoading={serviceNamesStatus === 'loading'}
hasNoInitialSelection
options={serviceNames.map(text => ({ text }))}
value={serviceName}
disabled={Boolean(selectedConfig)}
onChange={e => {
e.preventDefault();
setServiceName(e.target.value);
setEnvironment(undefined);
}}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceEnvironmentSelectLabel',
{
defaultMessage: 'Environment'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceEnvironmentSelectHelpText',
{
defaultMessage:
'Only a single environment per configuration is supported.'
}
)}
error={i18n.translate(
'xpack.apm.settings.cm.flyOut.serviceEnvironmentSelectErrorText',
{
defaultMessage:
'Must select a valid environement to save a configuration.'
}
)}
isInvalid={
!(
selectedConfig ||
(!selectedConfig &&
environment &&
isSelectedEnvironmentValid &&
environmentStatus === 'success') ||
isNaN(sampleRate)
)
}
>
<EuiSelect
key={serviceName} // rerender when serviceName changes to mitigate initial selection bug in EuiSelect
isLoading={environmentStatus === 'loading'}
hasNoInitialSelection
options={environmentOptions}
value={environment}
disabled={!serviceName || Boolean(selectedConfig)}
onChange={e => {
e.preventDefault();
setEnvironment(e.target.value);
}}
/>
</EuiFormRow>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.configurationSectionTitle',
{
defaultMessage: 'Configuration'
}
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.cm.flyOut.sampleRateConfigurationInputLabel',
{
defaultMessage: 'Transaction sample rate'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.cm.flyOut.sampleRateConfigurationInputHelpText',
{
defaultMessage:
'Choose a rate between 0.000 and 1.0. Default configuration is 1.0 (100% of traces).'
}
)}
error={i18n.translate(
'xpack.apm.settings.cm.flyOut.sampleRateConfigurationInputErrorText',
{
defaultMessage: 'Sample rate must be between 0.000 and 1'
}
)}
isInvalid={
!(
(Boolean(selectedConfig) &&
(isNaN(sampleRate) || isSampleRateValid)) ||
(!selectedConfig &&
(!(serviceName || environment) ||
(isNaN(sampleRate) || isSampleRateValid)))
)
}
>
<EuiFieldNumber
min={0}
max={1}
step={0.001}
placeholder={i18n.translate(
'xpack.apm.settings.cm.flyOut.sampleRateConfigurationInputPlaceholderText',
{
defaultMessage: 'Set sample rate'
}
)}
value={isNaN(sampleRate) ? '' : sampleRate}
onChange={e => {
e.preventDefault();
setSampleRate(parseFloat(e.target.value));
}}
/>
</EuiFormRow>
{selectedConfig ? (
<>
<EuiHorizontalRule margin="m" />
<EuiTitle size="xs">
<h3>
<EuiText color="danger">
{i18n.translate(
'xpack.apm.settings.cm.flyOut.deleteConfigurationSectionTitle',
{
defaultMessage: 'Delete configuration'
}
)}
</EuiText>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
<p>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.deleteConfigurationSectionText',
{
defaultMessage:
'If you wish to delete this configuration, please be aware that the agents will continue to use the existing configuration until they sync with the APM Server.'
}
)}
</p>
</EuiText>
<EuiSpacer size="s" />
<EuiButton fill={false} color="danger" onClick={onDelete}>
{i18n.translate(
'xpack.apm.settings.cm.flyOut.deleteConfigurationButtonLabel',
{
defaultMessage: 'Delete'
}
)}
</EuiButton>
<EuiSpacer size="m" />
</>
) : null}
</form>
</EuiForm>
);
}

View file

@ -0,0 +1,295 @@
/*
* 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 moment from 'moment';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiPanel,
EuiBetaBadge,
EuiSpacer,
EuiCallOut,
EuiEmptyPrompt,
EuiButton,
EuiLink
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { loadCMList } from '../../../services/rest/apm/settings';
import { useFetcher } from '../../../hooks/useFetcher';
import { ITableColumn, ManagedTable } from '../../shared/ManagedTable';
import { CMListAPIResponse } from '../../../../server/lib/settings/cm/list_configurations';
import { AddSettingsFlyout } from './AddSettings/AddSettingFlyout';
import { APMLink } from '../../shared/Links/APMLink';
export type Config = CMListAPIResponse[0];
export function SettingsList() {
const { data = [], refresh } = useFetcher(loadCMList, []);
const [selectedConfig, setSelectedConfig] = useState<Config | null>(null);
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
const COLUMNS: Array<ITableColumn<Config>> = [
{
field: 'service.name',
name: i18n.translate(
'xpack.apm.settings.cm.configTable.serviceNameColumnLabel',
{
defaultMessage: 'Service name'
}
),
sortable: true,
render: (_, config: Config) => (
<EuiButtonEmpty
size="s"
color="primary"
onClick={() => {
setSelectedConfig(config);
setIsFlyoutOpen(true);
}}
>
{config.service.name}
</EuiButtonEmpty>
)
},
{
field: 'service.environment',
name: i18n.translate(
'xpack.apm.settings.cm.configTable.environmentColumnLabel',
{
defaultMessage: 'Service environment'
}
),
sortable: true,
render: (value: string) => value
},
{
field: 'settings.sample_rate',
name: i18n.translate(
'xpack.apm.settings.cm.configTable.sampleRateColumnLabel',
{
defaultMessage: 'Sample rate'
}
),
sortable: true,
render: (value: string) => value
},
{
field: '@timestamp',
name: i18n.translate(
'xpack.apm.settings.cm.configTable.lastUpdatedColumnLabel',
{
defaultMessage: 'Last updated'
}
),
sortable: true,
render: (value: number) => (value ? moment(value).fromNow() : null)
},
{
name: '',
actions: [
{
name: i18n.translate(
'xpack.apm.settings.cm.configTable.editButtonLabel',
{
defaultMessage: 'Edit'
}
),
description: i18n.translate(
'xpack.apm.settings.cm.configTable.editButtonDescription',
{
defaultMessage: 'Edit this config'
}
),
icon: 'pencil',
color: 'primary',
type: 'icon',
onClick: (config: Config) => {
setSelectedConfig(config);
setIsFlyoutOpen(true);
}
}
]
}
];
const RETURN_TO_OVERVIEW_LINK_LABEL = i18n.translate(
'xpack.apm.settings.cm.returnToOverviewLinkLabel',
{
defaultMessage: 'Return to overview'
}
);
const hasConfigurations = !isEmpty(data);
const emptyState = (
<EuiEmptyPrompt
iconType="controlsHorizontal"
title={
<h2>
{i18n.translate(
'xpack.apm.settings.cm.configTable.emptyPromptTitle',
{ defaultMessage: 'No configurations found.' }
)}
</h2>
}
body={
<>
<p>
{i18n.translate(
'xpack.apm.settings.cm.configTable.emptyPromptText',
{
defaultMessage:
"Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration."
}
)}
</p>
</>
}
actions={
<EuiButton color="primary" fill onClick={() => setIsFlyoutOpen(true)}>
{i18n.translate('xpack.apm.settings.cm.createConfigButtonLabel', {
defaultMessage: 'Create configuration'
})}
</EuiButton>
}
/>
);
return (
<>
<AddSettingsFlyout
isOpen={isFlyoutOpen}
selectedConfig={selectedConfig}
onClose={() => {
setSelectedConfig(null);
setIsFlyoutOpen(false);
}}
onSubmit={() => {
setSelectedConfig(null);
setIsFlyoutOpen(false);
refresh();
}}
/>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
{i18n.translate('xpack.apm.settings.cm.pageTitle', {
defaultMessage: 'Settings'
})}
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<APMLink path="/">
<EuiButtonEmpty size="s" color="primary" iconType="arrowLeft">
{RETURN_TO_OVERVIEW_LINK_LABEL}
</EuiButtonEmpty>
</APMLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiPanel>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate(
'xpack.apm.settings.cm.configurationsPanelTitle',
{
defaultMessage: 'Configurations'
}
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate('xpack.apm.settings.cm.betaBadgeLabel', {
defaultMessage: 'Beta'
})}
tooltipContent={i18n.translate(
'xpack.apm.settings.cm.betaBadgeText',
{
defaultMessage:
'This feature is still in development. If you have feedback, please reach out in our Discuss forum.'
}
)}
/>
</EuiFlexItem>
{hasConfigurations ? (
<EuiFlexItem>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
fill
iconType="plusInCircle"
onClick={() => setIsFlyoutOpen(true)}
>
{i18n.translate(
'xpack.apm.settings.cm.createConfigButtonLabel',
{
defaultMessage: 'Create configuration'
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.translate('xpack.apm.settings.cm.betaCallOutTitle', {
defaultMessage: 'APM Agent Configuration (BETA)'
})}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.apm.settings.cm.betaCallOutText"
defaultMessage="We're excited to bring you a first look at APM Agent configuration. {agentConfigDocsLink}"
values={{
agentConfigDocsLink: (
<EuiLink href="https://www.elastic.co/guide/en/kibana/current/agent-configuration.html">
{i18n.translate(
'xpack.apm.settings.cm.agentConfigDocsLinkLabel',
{ defaultMessage: 'Learn more in our docs.' }
)}
</EuiLink>
)
}}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
{hasConfigurations ? (
<ManagedTable
columns={COLUMNS}
items={data}
initialSort={{ field: 'service.name', direction: 'asc' }}
initialPageSize={50}
/>
) : (
emptyState
)}
</EuiPanel>
</>
);
}

View file

@ -8,11 +8,13 @@ import { EuiBasicTable } from '@elastic/eui';
import { sortByOrder } from 'lodash';
import React, { Component } from 'react';
import { idx } from '@kbn/elastic-idx';
import { StringMap } from '../../../../typings/common';
// TODO: this should really be imported from EUI
export interface ITableColumn<T> {
field: string;
name: string;
actions?: StringMap[];
field?: string;
dataType?: string;
align?: string;
width?: string;
@ -35,7 +37,7 @@ interface Props<T> {
initialPageSize?: number;
hidePerPageOptions?: boolean;
initialSort?: {
field: Extract<keyof T, string>;
field: Required<ITableColumn<T>>['field'];
direction: string;
};
noItemsMessage?: React.ReactNode;

View file

@ -36,6 +36,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
});
@ -46,6 +47,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
});
@ -57,6 +59,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'response from hook',
error: undefined,
refresh: expect.any(Function),
status: 'success'
});
});
@ -77,6 +80,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
});
@ -87,6 +91,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
});
@ -98,6 +103,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: expect.any(Error),
refresh: expect.any(Function),
status: 'failure'
});
});
@ -118,6 +124,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
@ -127,6 +134,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
refresh: expect.any(Function),
status: 'success'
});
@ -145,6 +153,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
refresh: expect.any(Function),
status: 'loading'
});
@ -155,8 +164,40 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'second response',
error: undefined,
refresh: expect.any(Function),
status: 'success'
});
});
it('should return the same object reference when data is unchanged between rerenders', async () => {
const hook = renderHook(
({ callback, args }) => useFetcher(callback, args),
{
initialProps: {
callback: async () => 'data response',
args: ['a']
}
}
);
await hook.waitForNextUpdate();
const firstResult = hook.result.current;
hook.rerender();
const secondResult = hook.result.current;
// assert: subsequent rerender returns the same object reference
expect(secondResult === firstResult).toEqual(true);
hook.rerender({
callback: async () => {
return 'second response';
},
args: ['b']
});
await hook.waitForNextUpdate();
const thirdResult = hook.result.current;
// assert: rerender with different data returns a new object
expect(secondResult === thirdResult).toEqual(false);
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState, useMemo } from 'react';
import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext';
import { useComponentId } from './useComponentId';
@ -16,8 +16,10 @@ export enum FETCH_STATUS {
export function useFetcher<Response>(
fn: () => Promise<Response> | undefined,
useEffectKey: any[]
effectKey: any[],
options: { preservePreviousResponse?: boolean } = {}
) {
const { preservePreviousResponse = true } = options;
const id = useComponentId();
const { dispatchStatus } = useContext(LoadingIndicatorContext);
const [result, setResult] = useState<{
@ -25,47 +27,66 @@ export function useFetcher<Response>(
status?: FETCH_STATUS;
error?: Error;
}>({});
const [counter, setCounter] = useState(0);
useEffect(() => {
let didCancel = false;
useEffect(
() => {
let didCancel = false;
dispatchStatus({ id, isLoading: true });
setResult({
data: result.data, // preserve data from previous state while loading next state
status: FETCH_STATUS.LOADING,
error: undefined
});
async function doFetch() {
try {
const data = await fn();
if (!didCancel) {
dispatchStatus({ id, isLoading: false });
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined
});
async function doFetch() {
const promise = fn();
if (!promise) {
return;
}
} catch (e) {
if (!didCancel) {
dispatchStatus({ id, isLoading: false });
setResult({
data: undefined,
status: FETCH_STATUS.FAILURE,
error: e
});
dispatchStatus({ id, isLoading: true });
setResult({
data: preservePreviousResponse ? result.data : undefined, // preserve data from previous state while loading next state
status: FETCH_STATUS.LOADING,
error: undefined
});
try {
const data = await promise;
if (!didCancel) {
dispatchStatus({ id, isLoading: false });
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined
});
}
} catch (e) {
if (!didCancel) {
dispatchStatus({ id, isLoading: false });
setResult({
data: undefined,
status: FETCH_STATUS.FAILURE,
error: e
});
}
}
}
}
doFetch();
doFetch();
return () => {
dispatchStatus({ id, isLoading: false });
didCancel = true;
};
}, useEffectKey);
return () => {
dispatchStatus({ id, isLoading: false });
didCancel = true;
};
},
[...effectKey, counter]
);
return result || {};
return useMemo(
() => ({
...result,
refresh: () => {
// this will invalidate the effectKey and will result in a new request
setCounter(counter + 1);
}
}),
[result]
);
}

View file

@ -0,0 +1,63 @@
/*
* 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 { CMUpdateConfigurationAPIResponse } from '../../../../server/lib/settings/cm/update_configuration';
import { callApi } from '../callApi';
import { CentralConfigurationIntake } from '../../../../server/lib/settings/cm/configuration';
import { CMServicesAPIResponse } from '../../../../server/lib/settings/cm/get_service_names';
import { CMCreateConfigurationAPIResponse } from '../../../../server/lib/settings/cm/create_configuration';
import { CMListAPIResponse } from '../../../../server/lib/settings/cm/list_configurations';
import { CMEnvironmentsAPIResponse } from '../../../../server/lib/settings/cm/get_environments';
export async function loadCMServices() {
return callApi<CMServicesAPIResponse>({
pathname: `/api/apm/settings/cm/services`
});
}
export async function loadCMEnvironments({
serviceName
}: {
serviceName: string;
}) {
return callApi<CMEnvironmentsAPIResponse>({
pathname: `/api/apm/settings/cm/services/${serviceName}/environments`
});
}
export async function createCMConfiguration(
configuration: CentralConfigurationIntake
) {
return callApi<CMCreateConfigurationAPIResponse>({
pathname: `/api/apm/settings/cm/new`,
method: 'POST',
body: JSON.stringify(configuration)
});
}
export async function updateCMConfiguration(
configurationId: string,
configuration: CentralConfigurationIntake
) {
return callApi<CMUpdateConfigurationAPIResponse>({
pathname: `/api/apm/settings/cm/${configurationId}`,
method: 'PUT',
body: JSON.stringify(configuration)
});
}
export async function deleteCMConfiguration(configId: string) {
return callApi({
pathname: `/api/apm/settings/cm/${configId}`,
method: 'DELETE'
});
}
export async function loadCMList() {
return callApi<CMListAPIResponse>({
pathname: `/api/apm/settings/cm`
});
}

View file

@ -4,12 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import Joi, { Schema } from 'joi';
export const dateValidation = Joi.alternatives()
.try(Joi.date().iso(), Joi.number())
.required();
export const withDefaultValidators = (validators = {}) => {
export const withDefaultValidators = (
validators: { [key: string]: Schema } = {}
) => {
return Joi.object().keys({
_debug: Joi.bool(),
start: dateValidation,

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
export interface CentralConfigurationIntake {
settings: {
sample_rate: string;
};
service: {
name: string;
environment?: string;
};
}
export interface CentralConfiguration extends CentralConfigurationIntake {
'@timestamp': number;
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Server } from 'hapi';
import Boom from 'boom';
export async function createCmIndex(server: Server) {
const index = server.config().get<string>('apm_oss.cmIndex');
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster(
'admin'
);
const indexExists = await callWithInternalUser('indices.exists', { index });
if (!indexExists) {
const result = await callWithInternalUser('indices.create', {
index,
body: {
mappings: {
properties: {
'@timestamp': {
type: 'date'
},
settings: {
properties: {
sample_rate: {
type: 'text'
}
}
},
service: {
properties: {
name: {
type: 'keyword',
ignore_above: 1024
},
environment: {
type: 'keyword',
ignore_above: 1024
}
}
}
}
}
}
});
if (!result.acknowledged) {
const err = new Error(`Unable to create apm settings index '${index}'`);
// eslint-disable-next-line
console.error(err.stack);
throw Boom.boomify(err, { statusCode: 500 });
}
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 { PromiseReturnType } from '../../../../typings/common';
import { CentralConfigurationIntake } from './configuration';
export type CMCreateConfigurationAPIResponse = PromiseReturnType<
typeof createConfiguration
>;
export async function createConfiguration({
configuration,
setup
}: {
configuration: CentralConfigurationIntake;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
type: '_doc',
refresh: true,
index: config.get<string>('apm_oss.cmIndex'),
body: {
'@timestamp': Date.now(),
...configuration
}
};
return client.index(params);
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Setup } from '../../helpers/setup_request';
export async function deleteConfiguration({
configurationId,
setup
}: {
configurationId: string;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
refresh: 'wait_for',
index: config.get<string>('apm_oss.cmIndex'),
id: configurationId
};
return client.delete(params);
}

View file

@ -0,0 +1,57 @@
/*
* 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 {
PROCESSOR_EVENT,
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../../common/elasticsearch_fieldnames';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
export async function getAllEnvironments({
serviceName,
setup
}: {
serviceName: string;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
index: [
config.get<string>('apm_oss.metricsIndices'),
config.get<string>('apm_oss.errorIndices'),
config.get<string>('apm_oss.transactionIndices')
],
body: {
size: 0,
query: {
bool: {
filter: [
{
terms: { [PROCESSOR_EVENT]: ['transaction', 'error', 'metric'] }
},
{ term: { [SERVICE_NAME]: serviceName } }
]
}
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED,
size: 100
}
}
}
}
};
const resp = await client.search(params);
const buckets = resp.aggregations.environments.buckets;
return buckets.map(bucket => bucket.key);
}

View file

@ -0,0 +1,47 @@
/*
* 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 {
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../../common/elasticsearch_fieldnames';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
export async function getUnavailableEnvironments({
serviceName,
setup
}: {
serviceName: string;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
index: config.get<string>('apm_oss.cmIndex'),
body: {
size: 0,
query: {
bool: {
filter: [{ term: { [SERVICE_NAME]: serviceName } }]
}
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED,
size: 100
}
}
}
}
};
const resp = await client.search(params);
const buckets = resp.aggregations.environments.buckets;
return buckets.map(bucket => bucket.key);
}

View file

@ -0,0 +1,34 @@
/*
* 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 { getAllEnvironments } from './get_all_environments';
import { Setup } from '../../../helpers/setup_request';
import { PromiseReturnType } from '../../../../../typings/common';
import { getUnavailableEnvironments } from './get_unavailable_environments';
export type CMEnvironmentsAPIResponse = PromiseReturnType<
typeof getEnvironments
>;
export async function getEnvironments({
serviceName,
setup
}: {
serviceName: string;
setup: Setup;
}) {
const [allEnvironments, unavailableEnvironments] = await Promise.all([
getAllEnvironments({ serviceName, setup }),
getUnavailableEnvironments({ serviceName, setup })
]);
return allEnvironments.map(environment => {
return {
name: environment,
available: !unavailableEnvironments.includes(environment)
};
});
}

View file

@ -0,0 +1,47 @@
/*
* 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 { PromiseReturnType } from '../../../../typings/common';
import {
PROCESSOR_EVENT,
SERVICE_NAME
} from '../../../../common/elasticsearch_fieldnames';
export type CMServicesAPIResponse = PromiseReturnType<typeof getServiceNames>;
export async function getServiceNames({ setup }: { setup: Setup }) {
const { client, config } = setup;
const params = {
index: [
config.get<string>('apm_oss.metricsIndices'),
config.get<string>('apm_oss.errorIndices'),
config.get<string>('apm_oss.transactionIndices')
],
body: {
size: 0,
query: {
bool: {
filter: [
{ terms: { [PROCESSOR_EVENT]: ['transaction', 'error', 'metric'] } }
]
}
},
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: 100
}
}
}
}
};
const resp = await client.search(params);
const buckets = resp.aggregations.services.buckets;
return buckets.map(bucket => bucket.key);
}

View file

@ -0,0 +1,24 @@
/*
* 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 { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { CentralConfiguration } from './configuration';
export type CMListAPIResponse = PromiseReturnType<typeof listConfigurations>;
export async function listConfigurations({ setup }: { setup: Setup }) {
const { client, config } = setup;
const params = {
index: config.get<string>('apm_oss.cmIndex')
};
const resp = await client.search<CentralConfiguration>(params);
return resp.hits.hits.map(item => ({
id: item._id,
...item._source
}));
}

View file

@ -0,0 +1,52 @@
/*
* 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 { ESFilter } from 'elasticsearch';
import { PromiseReturnType } from '../../../../typings/common';
import {
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../common/elasticsearch_fieldnames';
import { Setup } from '../../helpers/setup_request';
import { CentralConfiguration } from './configuration';
export type CMSearchAPIResponse = PromiseReturnType<
typeof searchConfigurations
>;
export async function searchConfigurations({
serviceName,
environment,
setup
}: {
serviceName: string;
environment?: string;
setup: Setup;
}) {
const { client, config } = setup;
const filters: ESFilter[] = [{ term: { [SERVICE_NAME]: serviceName } }];
if (environment) {
filters.push({ term: { [SERVICE_ENVIRONMENT]: environment } });
} else {
filters.push({
bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } }
});
}
const params = {
index: config.get<string>('apm_oss.cmIndex'),
body: {
size: 1,
query: {
bool: { filter: filters }
}
}
};
const resp = await client.search<CentralConfiguration>(params);
return resp.hits.hits[0];
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Setup } from '../../helpers/setup_request';
import { PromiseReturnType } from '../../../../typings/common';
import { CentralConfigurationIntake } from './configuration';
export type CMUpdateConfigurationAPIResponse = PromiseReturnType<
typeof updateConfiguration
>;
export async function updateConfiguration({
configurationId,
configuration,
setup
}: {
configurationId: string;
configuration: CentralConfigurationIntake;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
type: '_doc',
id: configurationId,
refresh: true,
index: config.get<string>('apm_oss.cmIndex'),
body: {
'@timestamp': Date.now(),
...configuration
}
};
return client.index(params);
}

View file

@ -14,6 +14,7 @@ import { initTracesApi } from '../routes/traces';
import { initTransactionGroupsApi } from '../routes/transaction_groups';
import { initUIFiltersApi } from '../routes/ui_filters';
import { initIndexPatternApi } from '../routes/index_pattern';
import { initSettingsApi } from '../routes/settings';
export class Plugin {
public setup(core: InternalCoreSetup) {
@ -21,6 +22,7 @@ export class Plugin {
initTransactionGroupsApi(core);
initTracesApi(core);
initServicesApi(core);
initSettingsApi(core);
initErrorsApi(core);
initMetricsApi(core);
initIndexPatternApi(core);

View file

@ -0,0 +1,199 @@
/*
* 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 Boom from 'boom';
import { InternalCoreSetup } from 'src/core/server';
import Joi from 'joi';
import { setupRequest } from '../lib/helpers/setup_request';
import { getServiceNames } from '../lib/settings/cm/get_service_names';
import { createConfiguration } from '../lib/settings/cm/create_configuration';
import { updateConfiguration } from '../lib/settings/cm/update_configuration';
import { CentralConfigurationIntake } from '../lib/settings/cm/configuration';
import { searchConfigurations } from '../lib/settings/cm/search';
import { listConfigurations } from '../lib/settings/cm/list_configurations';
import { getEnvironments } from '../lib/settings/cm/get_environments';
import { deleteConfiguration } from '../lib/settings/cm/delete_configuration';
import { createCmIndex } from '../lib/settings/cm/create_cm_index';
const defaultErrorHandler = (err: Error) => {
// eslint-disable-next-line
console.error(err.stack);
throw Boom.boomify(err, { statusCode: 400 });
};
export function initSettingsApi(core: InternalCoreSetup) {
const { server } = core.http;
createCmIndex(server);
// get list of configurations
server.route({
method: 'GET',
path: `/api/apm/settings/cm`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
return await listConfigurations({
setup
}).catch(defaultErrorHandler);
}
});
// delete configuration
server.route({
method: 'DELETE',
path: `/api/apm/settings/cm/{configurationId}`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
const { configurationId } = req.params;
return await deleteConfiguration({
configurationId,
setup
}).catch(defaultErrorHandler);
}
});
// get list of services
server.route({
method: 'GET',
path: `/api/apm/settings/cm/services`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
return await getServiceNames({
setup
}).catch(defaultErrorHandler);
}
});
// get environments for service
server.route({
method: 'GET',
path: `/api/apm/settings/cm/services/{serviceName}/environments`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
const { serviceName } = req.params;
return await getEnvironments({
serviceName,
setup
}).catch(defaultErrorHandler);
}
});
// create configuration
server.route({
method: 'POST',
path: `/api/apm/settings/cm/new`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
const configuration = req.payload as CentralConfigurationIntake;
return await createConfiguration({
configuration,
setup
}).catch(defaultErrorHandler);
}
});
// update configuration
server.route({
method: 'PUT',
path: `/api/apm/settings/cm/{configurationId}`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async req => {
const setup = setupRequest(req);
const { configurationId } = req.params;
const configuration = req.payload as CentralConfigurationIntake;
return await updateConfiguration({
configurationId,
configuration,
setup
}).catch(defaultErrorHandler);
}
});
// Lookup single configuration
server.route({
method: 'POST',
path: `/api/apm/settings/cm/search`,
options: {
validate: {
query: {
_debug: Joi.bool()
}
},
tags: ['access:apm']
},
handler: async (req, h) => {
interface Payload {
service: {
name: string;
environment?: string;
};
}
const setup = setupRequest(req);
const payload = req.payload as Payload;
const serviceName = payload.service.name;
const environment = payload.service.environment;
const config = await searchConfigurations({
serviceName,
environment,
setup
});
if (!config) {
return h.response().code(404);
}
return config;
}
});
}