= {};
if (apiKey) {
requestHeaders.authorization = `ApiKey ${apiKey}`;
}
- const fakeRequest = {
+ return ({
headers: requestHeaders,
getBasePath: () => this.context.getBasePath(spaceId),
path: '/',
@@ -91,9 +91,11 @@ export class TaskRunner {
url: '/',
},
},
- };
+ } as unknown) as KibanaRequest;
+ }
- return this.context.getServices((fakeRequest as unknown) as KibanaRequest);
+ async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) {
+ return this.context.getServices(this.getFakeKibanaRequest(spaceId, apiKey));
}
private getExecutionHandler(
@@ -128,6 +130,7 @@ export class TaskRunner {
spaceId,
alertType: this.alertType,
eventLogger: this.context.eventLogger,
+ request: this.getFakeKibanaRequest(spaceId, apiKey),
});
}
diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js
index f1633799ea5832..2fae8bdab2b303 100644
--- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js
+++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js
@@ -1,10 +1,10 @@
module.exports = {
- "APM": {
- "Transaction duration charts": {
- "1": "350 ms",
- "2": "175 ms",
- "3": "0 ms"
- }
+ APM: {
+ 'Transaction duration charts': {
+ '1': '350.0 ms',
+ '2': '175.0 ms',
+ '3': '0.0 ms',
+ },
},
- "__version": "4.5.0"
-}
+ __version: '4.5.0',
+};
diff --git a/x-pack/plugins/apm/e2e/run-e2e.sh b/x-pack/plugins/apm/e2e/run-e2e.sh
index 4bebab8e0c6a80..aa7c0e21425ad8 100755
--- a/x-pack/plugins/apm/e2e/run-e2e.sh
+++ b/x-pack/plugins/apm/e2e/run-e2e.sh
@@ -164,6 +164,7 @@ echo "✅ Setup completed successfully. Running tests..."
# run cypress tests
##################################################
yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true
+e2e_status=$?
#
# Run interactively
@@ -171,3 +172,9 @@ yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true
echo "${bold}If you want to run the test interactively, run:${normal}"
echo "" # newline
echo "cd ${E2E_DIR} && yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true"
+
+# Report the e2e status at the very end
+if [ $e2e_status -ne 0 ]; then
+ echo "⚠️ Running tests failed."
+ exit 1
+fi
diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json
index 1b8e7c4dc54318..56a9e226b65284 100644
--- a/x-pack/plugins/apm/kibana.json
+++ b/x-pack/plugins/apm/kibana.json
@@ -25,5 +25,8 @@
"configPath": [
"xpack",
"apm"
+ ],
+ "extraPublicDirs": [
+ "public/style/variables"
]
}
diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js
index 927779b571fd8c..09fef5da16ae71 100644
--- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js
+++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js
@@ -40,7 +40,7 @@ describe('ServiceOverview -> List', () => {
expect(renderedColumns[0]).toMatchSnapshot();
expect(renderedColumns.slice(2)).toEqual([
'python',
- '92 ms',
+ '91.5 ms',
'86.9 tpm',
'12.6 err.',
]);
diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx
index b9048f013cb256..90cc9af45273e5 100644
--- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx
@@ -12,7 +12,7 @@ import styled from 'styled-components';
import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { fontSizes, truncate } from '../../../../style/variables';
-import { asDecimal, convertTo } from '../../../../utils/formatters';
+import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters';
import { ManagedTable } from '../../../shared/ManagedTable';
import { EnvironmentBadge } from '../../../shared/EnvironmentBadge';
import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink';
@@ -81,11 +81,7 @@ export const SERVICE_COLUMNS = [
}),
sortable: true,
dataType: 'number',
- render: (time: number) =>
- convertTo({
- unit: 'milliseconds',
- microseconds: time,
- }).formatted,
+ render: (time: number) => asMillisecondDuration(time),
},
{
field: 'transactionsPerMinute',
diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap
index 3e6be107ce3a1b..e89acca55d4fe5 100644
--- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap
+++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap
@@ -203,7 +203,7 @@ NodeList [
- 1 ms
+ 0.6 ms
- 0 ms
+ 0.3 ms
|
> = [
}),
sortable: true,
dataType: 'number',
- render: (time: number) =>
- convertTo({
- unit: 'milliseconds',
- microseconds: time,
- }).formatted,
+ render: (time: number) => asMillisecondDuration(time),
},
{
field: 'transactionsPerMinute',
diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx
index 61c8d3958b6250..ae1b07bde0c87a 100644
--- a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx
@@ -12,7 +12,7 @@ import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ITransactionGroup } from '../../../../../server/lib/transaction_groups/transform';
import { fontFamilyCode, truncate } from '../../../../style/variables';
-import { asDecimal, convertTo } from '../../../../utils/formatters';
+import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters';
import { ImpactBar } from '../../../shared/ImpactBar';
import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable';
import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt';
@@ -29,12 +29,6 @@ interface Props {
isLoading: boolean;
}
-const toMilliseconds = (time: number) =>
- convertTo({
- unit: 'milliseconds',
- microseconds: time,
- }).formatted;
-
export function TransactionList({ items, isLoading }: Props) {
const columns: Array> = useMemo(
() => [
@@ -74,7 +68,7 @@ export function TransactionList({ items, isLoading }: Props) {
),
sortable: true,
dataType: 'number',
- render: (time: number) => toMilliseconds(time),
+ render: (time: number) => asMillisecondDuration(time),
},
{
field: 'p95',
@@ -86,7 +80,7 @@ export function TransactionList({ items, isLoading }: Props) {
),
sortable: true,
dataType: 'number',
- render: (time: number) => toMilliseconds(time),
+ render: (time: number) => asMillisecondDuration(time),
},
{
field: 'transactionsPerMinute',
diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap
index 4c7d21d968088e..b7ea026f80fdea 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap
+++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap
@@ -9,7 +9,7 @@ Array [
"text":
Avg.
- 468 ms
+ 467.6 ms
,
},
@@ -2744,7 +2744,7 @@ Array [
- 468 ms
+ 467.6 ms
@@ -5923,7 +5923,7 @@ Array [
- 468 ms
+ 467.6 ms
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js
index 1d0a53843f5388..f84b0cfeda369c 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js
+++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js
@@ -78,7 +78,7 @@ describe('Histogram', () => {
const tooltips = wrapper.find('Tooltip');
expect(tooltips.length).toBe(1);
- expect(tooltips.prop('header')).toBe('811 - 927 ms');
+ expect(tooltips.prop('header')).toBe('811.1 - 926.9 ms');
expect(tooltips.prop('tooltipPoints')).toEqual([
{ value: '49.0 occurrences' },
]);
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap
index f1c7d4826fe0c7..700602eb56929d 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap
+++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap
@@ -127,7 +127,7 @@ exports[`Histogram Initially should have default markup 1`] = `
textAnchor="middle"
transform="translate(0, 18)"
>
- 0 ms
+ 0.0 ms
- 500 ms
+ 500.0 ms
- 1,000 ms
+ 1,000.0 ms
- 1,500 ms
+ 1,500.0 ms
- 2,000 ms
+ 2,000.0 ms
- 2,500 ms
+ 2,500.0 ms
- 3,000 ms
+ 3,000.0 ms
@@ -1477,7 +1477,7 @@ exports[`Histogram when hovering over a non-empty bucket should have correct mar
- 811 - 927 ms
+ 811.1 - 926.9 ms
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx
index d133ba5e715fd7..2b6f0c7aa1319f 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx
@@ -12,7 +12,7 @@ import { act } from '@testing-library/react-hooks';
import { expectTextsInDocument } from '../../../../../utils/testHelpers';
describe('ErrorMarker', () => {
- const mark = {
+ const mark = ({
id: 'agent',
offset: 10000,
type: 'errorMark',
@@ -20,18 +20,24 @@ describe('ErrorMarker', () => {
error: {
trace: { id: '123' },
transaction: { id: '456' },
- error: { grouping_key: '123' },
+ error: {
+ grouping_key: '123',
+ log: {
+ message:
+ "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.",
+ },
+ },
service: { name: 'bar' },
},
serviceColor: '#fff',
- } as ErrorMark;
+ } as unknown) as ErrorMark;
function openPopover(errorMark: ErrorMark) {
const component = render();
act(() => {
fireEvent.click(component.getByTestId('popover'));
});
- expectTextsInDocument(component, ['10,000 μs']);
+ expectTextsInDocument(component, ['10.0 ms']);
return component;
}
function getKueryDecoded(url: string) {
@@ -76,4 +82,34 @@ describe('ErrorMarker', () => {
const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement;
expect(getKueryDecoded(errorLink.hash)).toEqual('kuery=');
});
+ it('truncates the error message text', () => {
+ const { trace, transaction, ...withoutTraceAndTransaction } = mark.error;
+ const newMark = {
+ ...mark,
+ error: withoutTraceAndTransaction,
+ } as ErrorMark;
+ const component = openPopover(newMark);
+ const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement;
+ expect(errorLink.innerHTML).toHaveLength(241);
+ });
+
+ describe('when the error message is not longer than 240 characters', () => {
+ it('truncates the error message text', () => {
+ const newMark = ({
+ ...mark,
+ error: {
+ ...mark.error,
+ error: {
+ grouping_key: '123',
+ log: {
+ message: 'Blah.',
+ },
+ },
+ },
+ } as unknown) as ErrorMark;
+ const component = openPopover(newMark);
+ const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement;
+ expect(errorLink.innerHTML).toHaveLength(5);
+ });
+ });
});
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx
index 42f4f278b07bc1..e3310c273a55b2 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx
@@ -34,6 +34,7 @@ const TimeLegend = styled(Legend)`
const ErrorLink = styled(ErrorDetailLink)`
display: block;
margin: ${px(units.half)} 0 ${px(units.half)} 0;
+ overflow-wrap: break-word;
`;
const Button = styled(Legend)`
@@ -42,6 +43,16 @@ const Button = styled(Legend)`
align-items: flex-end;
`;
+// We chose 240 characters because it fits most error messages and it's still easily readable on a screen.
+function truncateMessage(errorMessage?: string) {
+ const maxLength = 240;
+ if (typeof errorMessage === 'string' && errorMessage.length > maxLength) {
+ return errorMessage.substring(0, maxLength) + '…';
+ } else {
+ return errorMessage;
+ }
+}
+
export const ErrorMarker: React.FC = ({ mark }) => {
const { urlParams } = useUrlParams();
const [isPopoverOpen, showPopover] = useState(false);
@@ -73,6 +84,10 @@ export const ErrorMarker: React.FC = ({ mark }) => {
rangeTo,
};
+ const errorMessage =
+ error.error.log?.message || error.error.exception?.[0]?.message;
+ const truncatedErrorMessage = truncateMessage(errorMessage);
+
return (
= ({ mark }) => {
serviceName={error.service.name}
errorGroupId={error.error.grouping_key}
query={query}
+ title={errorMessage}
>
- {error.error.log?.message || error.error.exception?.[0]?.message}
+ {truncatedErrorMessage}
diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx
index 3c1f4c54fc635c..915b55f29ef804 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx
@@ -120,7 +120,7 @@ export class TransactionCharts extends Component {
'xpack.apm.metrics.transactionChart.machineLearningTooltip',
{
defaultMessage:
- 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.',
+ 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores ≥ 75.',
}
)}
/>
diff --git a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts
index da9c32c84f36fd..2f0a30a5019a95 100644
--- a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts
+++ b/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts
@@ -62,7 +62,7 @@ describe('chartSelectors', () => {
{ x: 0, y: 100 },
{ x: 1000, y: 200 },
],
- legendValue: '0 ms',
+ legendValue: '200 μs',
title: 'Avg.',
type: 'linemark',
},
diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts
index cfe1a6a60cd227..f8aed9dcf6d9fb 100644
--- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts
+++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts
@@ -18,7 +18,7 @@ import {
RectCoordinate,
TimeSeries,
} from '../../typings/timeseries';
-import { asDecimal, tpmUnit, convertTo } from '../utils/formatters';
+import { asDecimal, asDuration, tpmUnit } from '../utils/formatters';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries';
import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor';
@@ -72,10 +72,7 @@ export function getResponseTimeSeries({
}: TimeSeriesAPIResponse) {
const { overallAvgDuration } = apmTimeseries;
const { avg, p95, p99 } = apmTimeseries.responseTimes;
- const formattedDuration = convertTo({
- unit: 'milliseconds',
- microseconds: overallAvgDuration,
- }).formatted;
+ const formattedDuration = asDuration(overallAvgDuration);
const series: TimeSeries[] = [
{
diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts
index de3e3868de396c..6d4b65d2aa9b41 100644
--- a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts
+++ b/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts
@@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { asDuration, convertTo, toMicroseconds } from '../duration';
+import { asDuration, toMicroseconds, asMillisecondDuration } from '../duration';
describe('duration formatters', () => {
describe('asDuration', () => {
@@ -14,10 +14,10 @@ describe('duration formatters', () => {
expect(asDuration(1)).toEqual('1 μs');
expect(asDuration(toMicroseconds(1, 'milliseconds'))).toEqual('1,000 μs');
expect(asDuration(toMicroseconds(1000, 'milliseconds'))).toEqual(
- '1,000 ms'
+ '1,000.0 ms'
);
expect(asDuration(toMicroseconds(10000, 'milliseconds'))).toEqual(
- '10,000 ms'
+ '10,000.0 ms'
);
expect(asDuration(toMicroseconds(20, 'seconds'))).toEqual('20.0 s');
expect(asDuration(toMicroseconds(10, 'minutes'))).toEqual('10.0 min');
@@ -30,96 +30,6 @@ describe('duration formatters', () => {
});
});
- describe('convertTo', () => {
- it('hours', () => {
- const unit = 'hours';
- const oneHourAsMicro = toMicroseconds(1, 'hours');
- const twoHourAsMicro = toMicroseconds(2, 'hours');
- expect(convertTo({ unit, microseconds: oneHourAsMicro })).toEqual({
- unit: 'h',
- value: '1.0',
- formatted: '1.0 h',
- });
- expect(convertTo({ unit, microseconds: twoHourAsMicro })).toEqual({
- unit: 'h',
- value: '2.0',
- formatted: '2.0 h',
- });
- expect(
- convertTo({ unit, microseconds: null, defaultValue: '1.2' })
- ).toEqual({ value: '1.2', formatted: '1.2' });
- });
-
- it('minutes', () => {
- const unit = 'minutes';
- const oneHourAsMicro = toMicroseconds(1, 'hours');
- const twoHourAsMicro = toMicroseconds(2, 'hours');
- expect(convertTo({ unit, microseconds: oneHourAsMicro })).toEqual({
- unit: 'min',
- value: '60.0',
- formatted: '60.0 min',
- });
- expect(convertTo({ unit, microseconds: twoHourAsMicro })).toEqual({
- unit: 'min',
- value: '120.0',
- formatted: '120.0 min',
- });
- expect(
- convertTo({ unit, microseconds: null, defaultValue: '10' })
- ).toEqual({ value: '10', formatted: '10' });
- });
-
- it('seconds', () => {
- const unit = 'seconds';
- const twentySecondsAsMicro = toMicroseconds(20, 'seconds');
- const thirtyFiveSecondsAsMicro = toMicroseconds(35, 'seconds');
- expect(convertTo({ unit, microseconds: twentySecondsAsMicro })).toEqual({
- unit: 's',
- value: '20.0',
- formatted: '20.0 s',
- });
- expect(
- convertTo({ unit, microseconds: thirtyFiveSecondsAsMicro })
- ).toEqual({ unit: 's', value: '35.0', formatted: '35.0 s' });
- expect(
- convertTo({ unit, microseconds: null, defaultValue: '10' })
- ).toEqual({ value: '10', formatted: '10' });
- });
-
- it('milliseconds', () => {
- const unit = 'milliseconds';
- const twentyMilliAsMicro = toMicroseconds(20, 'milliseconds');
- const thirtyFiveMilliAsMicro = toMicroseconds(35, 'milliseconds');
- expect(convertTo({ unit, microseconds: twentyMilliAsMicro })).toEqual({
- unit: 'ms',
- value: '20',
- formatted: '20 ms',
- });
- expect(
- convertTo({ unit, microseconds: thirtyFiveMilliAsMicro })
- ).toEqual({ unit: 'ms', value: '35', formatted: '35 ms' });
- expect(
- convertTo({ unit, microseconds: null, defaultValue: '10' })
- ).toEqual({ value: '10', formatted: '10' });
- });
-
- it('microseconds', () => {
- const unit = 'microseconds';
- expect(convertTo({ unit, microseconds: 20 })).toEqual({
- unit: 'μs',
- value: '20',
- formatted: '20 μs',
- });
- expect(convertTo({ unit, microseconds: 35 })).toEqual({
- unit: 'μs',
- value: '35',
- formatted: '35 μs',
- });
- expect(
- convertTo({ unit, microseconds: null, defaultValue: '10' })
- ).toEqual({ value: '10', formatted: '10' });
- });
- });
describe('toMicroseconds', () => {
it('transformes to microseconds', () => {
expect(toMicroseconds(1, 'hours')).toEqual(3600000000);
@@ -128,4 +38,10 @@ describe('duration formatters', () => {
expect(toMicroseconds(10, 'milliseconds')).toEqual(10000);
});
});
+
+ describe('asMilliseconds', () => {
+ it('converts to formatted decimal milliseconds', () => {
+ expect(asMillisecondDuration(0)).toEqual('0.0 ms');
+ });
+ });
});
diff --git a/x-pack/plugins/apm/public/utils/formatters/duration.ts b/x-pack/plugins/apm/public/utils/formatters/duration.ts
index af87f7d517cb93..a603faab37538f 100644
--- a/x-pack/plugins/apm/public/utils/formatters/duration.ts
+++ b/x-pack/plugins/apm/public/utils/formatters/duration.ts
@@ -65,7 +65,7 @@ const durationUnit: DurationUnit = {
defaultMessage: 'ms',
}),
convert: (value: number) =>
- asInteger(moment.duration(value / 1000).asMilliseconds()),
+ asDecimal(moment.duration(value / 1000).asMilliseconds()),
},
microseconds: {
label: i18n.translate('xpack.apm.formatters.microsTimeUnitLabel', {
@@ -77,13 +77,8 @@ const durationUnit: DurationUnit = {
/**
* Converts a microseconds value into the unit defined.
- *
- * @param param0
- * { unit: "milliseconds" | "hours" | "minutes" | "seconds" | "microseconds", microseconds, defaultValue }
- *
- * @returns object { value, unit, formatted }
*/
-export function convertTo({
+function convertTo({
unit,
microseconds,
defaultValue = NOT_AVAILABLE_LABEL,
@@ -118,7 +113,7 @@ function getDurationUnitKey(max: number): DurationTimeUnit {
if (max > toMicroseconds(10, 'seconds')) {
return 'seconds';
}
- if (max > toMicroseconds(10, 'milliseconds')) {
+ if (max > toMicroseconds(1, 'milliseconds')) {
return 'milliseconds';
}
return 'microseconds';
@@ -135,10 +130,6 @@ export const getDurationFormatter: TimeFormatterBuilder = memoize(
/**
* Converts value and returns it formatted - 00 unit
- *
- * @param value
- * @param param1 { defaultValue }
- * @returns formated value - 00 unit
*/
export function asDuration(
value: Maybe,
@@ -151,3 +142,15 @@ export function asDuration(
const formatter = getDurationFormatter(value);
return formatter(value, { defaultValue }).formatted;
}
+
+/**
+ * Convert a microsecond value to decimal milliseconds. Normally we use
+ * `asDuration`, but this is used in places like tables where we always want
+ * the same units.
+ */
+export function asMillisecondDuration(time: number) {
+ return convertTo({
+ unit: 'milliseconds',
+ microseconds: time,
+ }).formatted;
+}
diff --git a/x-pack/plugins/canvas/common/lib/autocomplete.ts b/x-pack/plugins/canvas/common/lib/autocomplete.ts
index 0ab549bd14e832..c97879de2137e6 100644
--- a/x-pack/plugins/canvas/common/lib/autocomplete.ts
+++ b/x-pack/plugins/canvas/common/lib/autocomplete.ts
@@ -14,7 +14,7 @@ import {
ExpressionFunction,
ExpressionFunctionParameter,
getByAlias,
-} from '../../../../../src/plugins/expressions';
+} from '../../../../../src/plugins/expressions/common';
const MARKER = 'CANVAS_SUGGESTION_MARKER';
diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json
index 443bb63a277997..1be55d2b7a6352 100644
--- a/x-pack/plugins/data_enhanced/kibana.json
+++ b/x-pack/plugins/data_enhanced/kibana.json
@@ -8,6 +8,7 @@
"requiredPlugins": [
"data"
],
+ "optionalPlugins": ["kibanaReact", "kibanaUtils"],
"server": true,
"ui": true
}
diff --git a/x-pack/plugins/features/kibana.json b/x-pack/plugins/features/kibana.json
index 1cab1821b1bf59..92fdd08e934784 100644
--- a/x-pack/plugins/features/kibana.json
+++ b/x-pack/plugins/features/kibana.json
@@ -6,5 +6,6 @@
"optionalPlugins": ["visTypeTimelion"],
"configPath": ["xpack", "features"],
"server": true,
- "ui": true
+ "ui": true,
+ "extraPublicDirs": ["common"]
}
diff --git a/x-pack/plugins/infra/common/alerting/metrics/index.ts b/x-pack/plugins/infra/common/alerting/metrics/index.ts
new file mode 100644
index 00000000000000..2c0a1bd9b2589c
--- /dev/null
+++ b/x-pack/plugins/infra/common/alerting/metrics/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 * from './types';
+export const INFRA_ALERT_PREVIEW_PATH = '/api/infra/alerting/preview';
+
+export const TOO_MANY_BUCKETS_PREVIEW_EXCEPTION = 'TOO_MANY_BUCKETS_PREVIEW_EXCEPTION';
+export interface TooManyBucketsPreviewExceptionMetadata {
+ TOO_MANY_BUCKETS_PREVIEW_EXCEPTION: any;
+ maxBuckets: number;
+}
+export const isTooManyBucketsPreviewException = (
+ value: any
+): value is TooManyBucketsPreviewExceptionMetadata =>
+ Boolean(value && value.TOO_MANY_BUCKETS_PREVIEW_EXCEPTION);
diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts
new file mode 100644
index 00000000000000..a6184080cb7746
--- /dev/null
+++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 rt from 'io-ts';
+
+// TODO: Have threshold and inventory alerts import these types from this file instead of from their
+// local directories
+export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold';
+export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold';
+
+export enum Comparator {
+ GT = '>',
+ LT = '<',
+ GT_OR_EQ = '>=',
+ LT_OR_EQ = '<=',
+ BETWEEN = 'between',
+ OUTSIDE_RANGE = 'outside',
+}
+
+export enum Aggregators {
+ COUNT = 'count',
+ AVERAGE = 'avg',
+ SUM = 'sum',
+ MIN = 'min',
+ MAX = 'max',
+ RATE = 'rate',
+ CARDINALITY = 'cardinality',
+ P95 = 'p95',
+ P99 = 'p99',
+}
+
+// Alert Preview API
+const baseAlertRequestParamsRT = rt.intersection([
+ rt.partial({
+ filterQuery: rt.union([rt.string, rt.undefined]),
+ sourceId: rt.string,
+ }),
+ rt.type({
+ lookback: rt.union([rt.literal('h'), rt.literal('d'), rt.literal('w'), rt.literal('M')]),
+ criteria: rt.array(rt.any),
+ alertInterval: rt.string,
+ }),
+]);
+
+const metricThresholdAlertPreviewRequestParamsRT = rt.intersection([
+ baseAlertRequestParamsRT,
+ rt.partial({
+ groupBy: rt.union([rt.string, rt.array(rt.string), rt.undefined]),
+ }),
+ rt.type({
+ alertType: rt.literal(METRIC_THRESHOLD_ALERT_TYPE_ID),
+ }),
+]);
+export type MetricThresholdAlertPreviewRequestParams = rt.TypeOf<
+ typeof metricThresholdAlertPreviewRequestParamsRT
+>;
+
+const inventoryAlertPreviewRequestParamsRT = rt.intersection([
+ baseAlertRequestParamsRT,
+ rt.type({
+ nodeType: rt.string,
+ alertType: rt.literal(METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID),
+ }),
+]);
+
+export const alertPreviewRequestParamsRT = rt.union([
+ metricThresholdAlertPreviewRequestParamsRT,
+ inventoryAlertPreviewRequestParamsRT,
+]);
+
+export const alertPreviewSuccessResponsePayloadRT = rt.type({
+ numberOfGroups: rt.number,
+ resultTotals: rt.type({
+ fired: rt.number,
+ noData: rt.number,
+ error: rt.number,
+ tooManyBuckets: rt.number,
+ }),
+});
diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx
index d5d61733e87176..febf849ccc9438 100644
--- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx
+++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx
@@ -4,25 +4,36 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { debounce } from 'lodash';
+import { debounce, pick } from 'lodash';
+import * as rt from 'io-ts';
+import { HttpSetup } from 'src/core/public';
import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react';
import {
EuiSpacer,
EuiText,
EuiFormRow,
+ EuiButton,
EuiButtonEmpty,
EuiCheckbox,
EuiToolTip,
EuiIcon,
EuiFieldSearch,
+ EuiSelect,
+ EuiFlexGroup,
+ EuiFlexItem,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds';
import {
Comparator,
Aggregators,
- // eslint-disable-next-line @kbn/eslint/no-restricted-paths
-} from '../../../../server/lib/alerting/metric_threshold/types';
+ INFRA_ALERT_PREVIEW_PATH,
+ alertPreviewRequestParamsRT,
+ alertPreviewSuccessResponsePayloadRT,
+ METRIC_THRESHOLD_ALERT_TYPE_ID,
+} from '../../../../common/alerting/metrics';
import {
ForLastExpression,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
@@ -40,6 +51,7 @@ import { convertKueryToElasticSearchQuery } from '../../../utils/kuery';
import { ExpressionRow } from './expression_row';
import { AlertContextMeta, TimeUnit, MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
+import { validateMetricThreshold } from './validation';
const FILTER_TYPING_DEBOUNCE_MS = 500;
@@ -54,6 +66,7 @@ interface Props {
alertOnNoData?: boolean;
};
alertsContext: AlertsContextValue;
+ alertInterval: string;
setAlertParams(key: string, value: any): void;
setAlertProperty(key: string, value: any): void;
}
@@ -66,8 +79,24 @@ const defaultExpression = {
timeUnit: 'm',
} as MetricExpression;
+async function getAlertPreview({
+ fetch,
+ params,
+}: {
+ fetch: HttpSetup['fetch'];
+ params: rt.TypeOf;
+}): Promise> {
+ return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, {
+ method: 'POST',
+ body: JSON.stringify({
+ ...params,
+ alertType: METRIC_THRESHOLD_ALERT_TYPE_ID,
+ }),
+ });
+}
+
export const Expressions: React.FC = (props) => {
- const { setAlertParams, alertParams, errors, alertsContext } = props;
+ const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props;
const { source, createDerivedIndexPattern } = useSourceViaHttp({
sourceId: 'default',
type: 'metrics',
@@ -75,6 +104,13 @@ export const Expressions: React.FC = (props) => {
toastWarning: alertsContext.toastNotifications.addWarning,
});
+ const [previewLookbackInterval, setPreviewLookbackInterval] = useState('h');
+ const [isPreviewLoading, setIsPreviewLoading] = useState(false);
+ const [previewError, setPreviewError] = useState(false);
+ const [previewResult, setPreviewResult] = useState | null>(null);
+
const [timeSize, setTimeSize] = useState(1);
const [timeUnit, setTimeUnit] = useState('m');
const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [
@@ -143,7 +179,7 @@ export const Expressions: React.FC = (props) => {
const onGroupByChange = useCallback(
(group: string | null | string[]) => {
- setAlertParams('groupBy', group || '');
+ setAlertParams('groupBy', group && group.length ? group : '');
},
[setAlertParams]
);
@@ -224,6 +260,33 @@ export const Expressions: React.FC = (props) => {
}
}, [alertsContext.metadata, derivedIndexPattern, setAlertParams]);
+ const onSelectPreviewLookbackInterval = useCallback((e) => {
+ setPreviewLookbackInterval(e.target.value);
+ setPreviewResult(null);
+ }, []);
+
+ const onClickPreview = useCallback(async () => {
+ setIsPreviewLoading(true);
+ setPreviewResult(null);
+ setPreviewError(false);
+ try {
+ const result = await getAlertPreview({
+ fetch: alertsContext.http.fetch,
+ params: {
+ ...pick(alertParams, 'criteria', 'groupBy', 'filterQuery'),
+ sourceId: alertParams.sourceId,
+ lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M',
+ alertInterval,
+ },
+ });
+ setPreviewResult(result);
+ } catch (e) {
+ setPreviewError(true);
+ } finally {
+ setIsPreviewLoading(false);
+ }
+ }, [alertParams, alertInterval, alertsContext, previewLookbackInterval]);
+
useEffect(() => {
if (alertParams.criteria && alertParams.criteria.length) {
setTimeSize(alertParams.criteria[0].timeSize);
@@ -246,6 +309,23 @@ export const Expressions: React.FC = (props) => {
[onFilterChange]
);
+ const previewIntervalError = useMemo(() => {
+ const intervalInSeconds = getIntervalInSeconds(alertInterval);
+ const lookbackInSeconds = getIntervalInSeconds(`1${previewLookbackInterval}`);
+ if (intervalInSeconds >= lookbackInSeconds) {
+ return true;
+ }
+ return false;
+ }, [previewLookbackInterval, alertInterval]);
+
+ const isPreviewDisabled = useMemo(() => {
+ const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any);
+ const hasValidationErrors = Object.values(validationResult.errors).some((result) =>
+ Object.values(result).some((arr) => Array.isArray(arr) && arr.length)
+ );
+ return hasValidationErrors || previewIntervalError;
+ }, [alertParams.criteria, previewIntervalError]);
+
return (
<>
@@ -381,10 +461,191 @@ export const Expressions: React.FC = (props) => {
}}
/>
+
+
+
+ <>
+
+
+
+
+
+
+ {i18n.translate('xpack.infra.metrics.alertFlyout.testAlertTrigger', {
+ defaultMessage: 'Test alert trigger',
+ })}
+
+
+
+
+ {previewResult && !previewIntervalError && !previewResult.resultTotals.tooManyBuckets && (
+ <>
+
+
+ {previewResult.resultTotals.fired},
+ lookback: previewOptions.find((e) => e.value === previewLookbackInterval)
+ ?.shortText,
+ }}
+ />{' '}
+ {alertParams.groupBy ? (
+ {previewResult.numberOfGroups},
+ groupName: alertParams.groupBy,
+ plural: previewResult.numberOfGroups !== 1 ? 's' : '',
+ }}
+ />
+ ) : (
+
+ )}
+
+ {alertParams.alertOnNoData && previewResult.resultTotals.noData ? (
+ <>
+
+
+ {previewResult.resultTotals.noData},
+ plural: previewResult.resultTotals.noData !== 1 ? 's' : '',
+ }}
+ />
+
+ >
+ ) : null}
+ {previewResult.resultTotals.error ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+ >
+ )}
+ {previewResult && previewResult.resultTotals.tooManyBuckets ? (
+ <>
+
+
+ FOR THE LAST,
+ }}
+ />
+
+ >
+ ) : null}
+ {previewIntervalError && (
+ <>
+
+
+ check every,
+ }}
+ />
+
+ >
+ )}
+ {previewError && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+
+
>
);
};
+const previewOptions = [
+ {
+ value: 'h',
+ text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', {
+ defaultMessage: 'Last hour',
+ }),
+ shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', {
+ defaultMessage: 'hour',
+ }),
+ },
+ {
+ value: 'd',
+ text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', {
+ defaultMessage: 'Last day',
+ }),
+ shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', {
+ defaultMessage: 'day',
+ }),
+ },
+ {
+ value: 'w',
+ text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', {
+ defaultMessage: 'Last week',
+ }),
+ shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', {
+ defaultMessage: 'week',
+ }),
+ },
+ {
+ value: 'M',
+ text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', {
+ defaultMessage: 'Last month',
+ }),
+ shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', {
+ defaultMessage: 'month',
+ }),
+ },
+];
+
+const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', {
+ defaultMessage: 'time',
+});
+const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', {
+ defaultMessage: 'times',
+});
+
// required for dynamic import
// eslint-disable-next-line import/no-default-export
export default Expressions;
diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts
index 06135c6532d778..6fbdeff950d1a4 100644
--- a/x-pack/plugins/infra/server/infra_server.ts
+++ b/x-pack/plugins/infra/server/infra_server.ts
@@ -32,6 +32,7 @@ import {
import { initInventoryMetaRoute } from './routes/inventory_metadata';
import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources';
import { initSourceRoute } from './routes/source';
+import { initAlertPreviewRoute } from './routes/alerting';
export const initInfraServer = (libs: InfraBackendLibs) => {
const schema = makeExecutableSchema({
@@ -64,4 +65,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => {
initInventoryMetaRoute(libs);
initLogSourceConfigurationRoutes(libs);
initLogSourceStatusRoutes(libs);
+ initAlertPreviewRoute(libs);
};
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts
similarity index 93%
rename from x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts
rename to x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts
index 2c83f6ecfd7056..3a5c53ca80880d 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { Aggregators } from './types';
+import { Aggregators } from '../types';
export const createPercentileAggregation = (
type: Aggregators.P95 | Aggregators.P99,
field: string
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts
new file mode 100644
index 00000000000000..49b191c4e85c95
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts
@@ -0,0 +1,190 @@
+/*
+ * 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 { mapValues, first, last, isNaN } from 'lodash';
+import {
+ TooManyBucketsPreviewExceptionMetadata,
+ isTooManyBucketsPreviewException,
+ TOO_MANY_BUCKETS_PREVIEW_EXCEPTION,
+} from '../../../../../common/alerting/metrics';
+import { InfraSource } from '../../../../../common/http_api/source_api';
+import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types';
+import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler';
+import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server';
+import { getAllCompositeData } from '../../../../utils/get_all_composite_data';
+import { MetricExpressionParams, Comparator, Aggregators } from '../types';
+import { DOCUMENT_COUNT_I18N } from '../messages';
+import { getElasticsearchMetricQuery } from './metric_query';
+
+interface Aggregation {
+ aggregatedIntervals: {
+ buckets: Array<{
+ aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> };
+ doc_count: number;
+ }>;
+ };
+}
+
+interface CompositeAggregationsResponse {
+ groupings: {
+ buckets: Aggregation[];
+ };
+}
+
+export const evaluateAlert = (
+ callCluster: AlertServices['callCluster'],
+ params: AlertExecutorOptions['params'],
+ config: InfraSource['configuration'],
+ timeframe?: { start: number; end: number }
+) => {
+ const { criteria, groupBy, filterQuery } = params as {
+ criteria: MetricExpressionParams[];
+ groupBy: string | undefined | string[];
+ filterQuery: string | undefined;
+ };
+ return Promise.all(
+ criteria.map(async (criterion) => {
+ const currentValues = await getMetric(
+ callCluster,
+ criterion,
+ config.metricAlias,
+ config.fields.timestamp,
+ groupBy,
+ filterQuery,
+ timeframe
+ );
+ const { threshold, comparator } = criterion;
+ const comparisonFunction = comparatorMap[comparator];
+ return mapValues(
+ currentValues,
+ (values: number | number[] | null | TooManyBucketsPreviewExceptionMetadata) => {
+ if (isTooManyBucketsPreviewException(values)) throw values;
+ return {
+ ...criterion,
+ metric: criterion.metric ?? DOCUMENT_COUNT_I18N,
+ currentValue: Array.isArray(values) ? last(values) : NaN,
+ shouldFire: Array.isArray(values)
+ ? values.map((value) => comparisonFunction(value, threshold))
+ : [false],
+ isNoData: values === null,
+ isError: isNaN(values),
+ };
+ }
+ );
+ })
+ );
+};
+
+const getMetric: (
+ callCluster: AlertServices['callCluster'],
+ params: MetricExpressionParams,
+ index: string,
+ timefield: string,
+ groupBy: string | undefined | string[],
+ filterQuery: string | undefined,
+ timeframe?: { start: number; end: number }
+) => Promise> = async function (
+ callCluster,
+ params,
+ index,
+ timefield,
+ groupBy,
+ filterQuery,
+ timeframe
+) {
+ const { aggType } = params;
+ const hasGroupBy = groupBy && groupBy.length;
+ const searchBody = getElasticsearchMetricQuery(
+ params,
+ timefield,
+ hasGroupBy ? groupBy : undefined,
+ filterQuery,
+ timeframe
+ );
+
+ try {
+ if (hasGroupBy) {
+ const bucketSelector = (
+ response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse>
+ ) => response.aggregations?.groupings?.buckets || [];
+ const afterKeyHandler = createAfterKeyHandler(
+ 'aggs.groupings.composite.after',
+ (response) => response.aggregations?.groupings?.after_key
+ );
+ const compositeBuckets = (await getAllCompositeData(
+ (body) => callCluster('search', { body, index }),
+ searchBody,
+ bucketSelector,
+ afterKeyHandler
+ )) as Array }>;
+ return compositeBuckets.reduce(
+ (result, bucket) => ({
+ ...result,
+ [Object.values(bucket.key)
+ .map((value) => value)
+ .join(', ')]: getValuesFromAggregations(bucket, aggType),
+ }),
+ {}
+ );
+ }
+ const result = await callCluster('search', {
+ body: searchBody,
+ index,
+ });
+
+ return { '*': getValuesFromAggregations(result.aggregations, aggType) };
+ } catch (e) {
+ if (timeframe) {
+ // This code should only ever be reached when previewing the alert, not executing it
+ const causedByType = e.body?.error?.caused_by?.type;
+ if (causedByType === 'too_many_buckets_exception') {
+ return {
+ '*': {
+ [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true,
+ maxBuckets: e.body.error.caused_by.max_buckets,
+ },
+ };
+ }
+ }
+ return { '*': NaN }; // Trigger an Error state
+ }
+};
+
+const getValuesFromAggregations = (
+ aggregations: Aggregation,
+ aggType: MetricExpressionParams['aggType']
+) => {
+ try {
+ const { buckets } = aggregations.aggregatedIntervals;
+ if (!buckets.length) return null; // No Data state
+ if (aggType === Aggregators.COUNT) {
+ return buckets.map((bucket) => bucket.doc_count);
+ }
+ if (aggType === Aggregators.P95 || aggType === Aggregators.P99) {
+ return buckets.map((bucket) => {
+ const values = bucket.aggregatedValue?.values || [];
+ const firstValue = first(values);
+ if (!firstValue) return null;
+ return firstValue.value;
+ });
+ }
+ return buckets.map((bucket) => bucket.aggregatedValue.value);
+ } catch (e) {
+ return NaN; // Error state
+ }
+};
+
+const comparatorMap = {
+ [Comparator.BETWEEN]: (value: number, [a, b]: number[]) =>
+ value >= Math.min(a, b) && value <= Math.max(a, b),
+ [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b,
+ // `threshold` is always an array of numbers in case the BETWEEN/OUTSIDE_RANGE comparator is
+ // used; all other compartors will just destructure the first value in the array
+ [Comparator.GT]: (a: number, [b]: number[]) => a > b,
+ [Comparator.LT]: (a: number, [b]: number[]) => a < b,
+ [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b,
+ [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
+};
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts
new file mode 100644
index 00000000000000..5680035d9d609b
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts
@@ -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 { networkTraffic } from '../../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
+import { MetricExpressionParams, Aggregators } from '../types';
+import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds';
+import { getDateHistogramOffset } from '../../../snapshot/query_helpers';
+import { createPercentileAggregation } from './create_percentile_aggregation';
+
+const MINIMUM_BUCKETS = 5;
+
+const getParsedFilterQuery: (
+ filterQuery: string | undefined
+) => Record | Array> = (filterQuery) => {
+ if (!filterQuery) return {};
+ return JSON.parse(filterQuery).bool;
+};
+
+export const getElasticsearchMetricQuery = (
+ { metric, aggType, timeUnit, timeSize }: MetricExpressionParams,
+ timefield: string,
+ groupBy?: string | string[],
+ filterQuery?: string,
+ timeframe?: { start: number; end: number }
+) => {
+ if (aggType === Aggregators.COUNT && metric) {
+ throw new Error('Cannot aggregate document count with a metric');
+ }
+ if (aggType !== Aggregators.COUNT && !metric) {
+ throw new Error('Can only aggregate without a metric if using the document count aggregator');
+ }
+ const interval = `${timeSize}${timeUnit}`;
+ const intervalAsSeconds = getIntervalInSeconds(interval);
+
+ const to = timeframe ? timeframe.end : Date.now();
+ // We need enough data for 5 buckets worth of data. We also need
+ // to convert the intervalAsSeconds to milliseconds.
+ const minimumFrom = to - intervalAsSeconds * 1000 * MINIMUM_BUCKETS;
+
+ const from = timeframe && timeframe.start <= minimumFrom ? timeframe.start : minimumFrom;
+
+ const offset = getDateHistogramOffset(from, interval);
+
+ const aggregations =
+ aggType === Aggregators.COUNT
+ ? {}
+ : aggType === Aggregators.RATE
+ ? networkTraffic('aggregatedValue', metric)
+ : aggType === Aggregators.P95 || aggType === Aggregators.P99
+ ? createPercentileAggregation(aggType, metric)
+ : {
+ aggregatedValue: {
+ [aggType]: {
+ field: metric,
+ },
+ },
+ };
+
+ const baseAggs = {
+ aggregatedIntervals: {
+ date_histogram: {
+ field: timefield,
+ fixed_interval: interval,
+ offset,
+ extended_bounds: {
+ min: from,
+ max: to,
+ },
+ },
+ aggregations,
+ },
+ };
+
+ const aggs = groupBy
+ ? {
+ groupings: {
+ composite: {
+ size: 10,
+ sources: Array.isArray(groupBy)
+ ? groupBy.map((field, index) => ({
+ [`groupBy${index}`]: {
+ terms: { field },
+ },
+ }))
+ : [
+ {
+ groupBy0: {
+ terms: {
+ field: groupBy,
+ },
+ },
+ },
+ ],
+ },
+ aggs: baseAggs,
+ },
+ }
+ : baseAggs;
+
+ const rangeFilters = [
+ {
+ range: {
+ '@timestamp': {
+ gte: from,
+ lte: to,
+ format: 'epoch_millis',
+ },
+ },
+ },
+ ];
+
+ const metricFieldFilters = metric
+ ? [
+ {
+ exists: {
+ field: metric,
+ },
+ },
+ ]
+ : [];
+
+ const parsedFilterQuery = getParsedFilterQuery(filterQuery);
+
+ return {
+ query: {
+ bool: {
+ filter: [
+ ...rangeFilters,
+ ...metricFieldFilters,
+ ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []),
+ ],
+ ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}),
+ },
+ },
+ size: 0,
+ aggs,
+ };
+};
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
index 8260ebed846222..f28137d980b9f1 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts
@@ -383,34 +383,6 @@ const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: {
}) => Promise;
const services: AlertServicesMock = alertsMock.createAlertServices();
-services.callCluster.mockImplementation(async (_: string, { body, index }: any) => {
- if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse;
- const metric = body.query.bool.filter[1]?.exists.field;
- if (body.aggs.groupings) {
- if (body.aggs.groupings.composite.after) {
- return mocks.compositeEndResponse;
- }
- if (metric === 'test.metric.2') {
- return mocks.alternateCompositeResponse;
- }
- return mocks.basicCompositeResponse;
- }
- if (metric === 'test.metric.2') {
- return mocks.alternateMetricResponse;
- }
- return mocks.basicMetricResponse;
-});
-services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => {
- if (sourceId === 'alternate')
- return {
- id: 'alternate',
- attributes: { metricAlias: 'alternatebeat-*' },
- type,
- references: [],
- };
- return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] };
-});
-
services.callCluster.mockImplementation(async (_: string, { body, index }: any) => {
if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse;
const metric = body.query.bool.filter[1]?.exists.field;
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
index 00a1d97dec811a..4fe28fad68c85c 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts
@@ -3,263 +3,25 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { mapValues, first } from 'lodash';
+import { first, last } from 'lodash';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
-import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types';
-import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler';
-import { getAllCompositeData } from '../../../utils/get_all_composite_data';
-import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
-import { MetricExpressionParams, Comparator, Aggregators, AlertStates } from './types';
+import { AlertExecutorOptions } from '../../../../../alerts/server';
+import { InfraBackendLibs } from '../../infra_types';
+import { AlertStates } from './types';
+import { evaluateAlert } from './lib/evaluate_alert';
import {
buildErrorAlertReason,
buildFiredAlertReason,
buildNoDataAlertReason,
- DOCUMENT_COUNT_I18N,
stateToAlertMessage,
} from './messages';
-import { AlertServices, AlertExecutorOptions } from '../../../../../alerts/server';
-import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
-import { getDateHistogramOffset } from '../../snapshot/query_helpers';
-import { InfraBackendLibs } from '../../infra_types';
-import { createPercentileAggregation } from './create_percentile_aggregation';
-
-const TOTAL_BUCKETS = 5;
-
-interface Aggregation {
- aggregatedIntervals: {
- buckets: Array<{
- aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> };
- doc_count: number;
- }>;
- };
-}
-
-interface CompositeAggregationsResponse {
- groupings: {
- buckets: Aggregation[];
- };
-}
-
-const getCurrentValueFromAggregations = (
- aggregations: Aggregation,
- aggType: MetricExpressionParams['aggType']
-) => {
- try {
- const { buckets } = aggregations.aggregatedIntervals;
- if (!buckets.length) return null; // No Data state
- const mostRecentBucket = buckets[buckets.length - 1];
- if (aggType === Aggregators.COUNT) {
- return mostRecentBucket.doc_count;
- }
- if (aggType === Aggregators.P95 || aggType === Aggregators.P99) {
- const values = mostRecentBucket.aggregatedValue?.values || [];
- const firstValue = first(values);
- if (!firstValue) return null;
- return firstValue.value;
- }
- const { value } = mostRecentBucket.aggregatedValue;
- return value;
- } catch (e) {
- return undefined; // Error state
- }
-};
-
-const getParsedFilterQuery: (
- filterQuery: string | undefined
-) => Record | Array> = (filterQuery) => {
- if (!filterQuery) return {};
- return JSON.parse(filterQuery).bool;
-};
-
-export const getElasticsearchMetricQuery = (
- { metric, aggType, timeUnit, timeSize }: MetricExpressionParams,
- timefield: string,
- groupBy?: string | string[],
- filterQuery?: string
-) => {
- if (aggType === Aggregators.COUNT && metric) {
- throw new Error('Cannot aggregate document count with a metric');
- }
- if (aggType !== Aggregators.COUNT && !metric) {
- throw new Error('Can only aggregate without a metric if using the document count aggregator');
- }
- const interval = `${timeSize}${timeUnit}`;
- const to = Date.now();
- const intervalAsSeconds = getIntervalInSeconds(interval);
- // We need enough data for 5 buckets worth of data. We also need
- // to convert the intervalAsSeconds to milliseconds.
- const from = to - intervalAsSeconds * 1000 * TOTAL_BUCKETS;
- const offset = getDateHistogramOffset(from, interval);
-
- const aggregations =
- aggType === Aggregators.COUNT
- ? {}
- : aggType === Aggregators.RATE
- ? networkTraffic('aggregatedValue', metric)
- : aggType === Aggregators.P95 || aggType === Aggregators.P99
- ? createPercentileAggregation(aggType, metric)
- : {
- aggregatedValue: {
- [aggType]: {
- field: metric,
- },
- },
- };
-
- const baseAggs = {
- aggregatedIntervals: {
- date_histogram: {
- field: timefield,
- fixed_interval: interval,
- offset,
- extended_bounds: {
- min: from,
- max: to,
- },
- },
- aggregations,
- },
- };
-
- const aggs = groupBy
- ? {
- groupings: {
- composite: {
- size: 10,
- sources: Array.isArray(groupBy)
- ? groupBy.map((field, index) => ({
- [`groupBy${index}`]: {
- terms: { field },
- },
- }))
- : [
- {
- groupBy0: {
- terms: {
- field: groupBy,
- },
- },
- },
- ],
- },
- aggs: baseAggs,
- },
- }
- : baseAggs;
-
- const rangeFilters = [
- {
- range: {
- '@timestamp': {
- gte: from,
- lte: to,
- format: 'epoch_millis',
- },
- },
- },
- ];
-
- const metricFieldFilters = metric
- ? [
- {
- exists: {
- field: metric,
- },
- },
- ]
- : [];
-
- const parsedFilterQuery = getParsedFilterQuery(filterQuery);
-
- return {
- query: {
- bool: {
- filter: [
- ...rangeFilters,
- ...metricFieldFilters,
- ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []),
- ],
- ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}),
- },
- },
- size: 0,
- aggs,
- };
-};
-
-const getMetric: (
- services: AlertServices,
- params: MetricExpressionParams,
- index: string,
- timefield: string,
- groupBy: string | undefined | string[],
- filterQuery: string | undefined
-) => Promise> = async function (
- { callCluster },
- params,
- index,
- timefield,
- groupBy,
- filterQuery
-) {
- const { aggType } = params;
- const searchBody = getElasticsearchMetricQuery(params, timefield, groupBy, filterQuery);
-
- try {
- if (groupBy) {
- const bucketSelector = (
- response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse>
- ) => response.aggregations?.groupings?.buckets || [];
- const afterKeyHandler = createAfterKeyHandler(
- 'aggs.groupings.composite.after',
- (response) => response.aggregations?.groupings?.after_key
- );
- const compositeBuckets = (await getAllCompositeData(
- (body) => callCluster('search', { body, index }),
- searchBody,
- bucketSelector,
- afterKeyHandler
- )) as Array }>;
- return compositeBuckets.reduce(
- (result, bucket) => ({
- ...result,
- [Object.values(bucket.key)
- .map((value) => value)
- .join(', ')]: getCurrentValueFromAggregations(bucket, aggType),
- }),
- {}
- );
- }
- const result = await callCluster('search', {
- body: searchBody,
- index,
- });
-
- return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) };
- } catch (e) {
- return { '*': undefined }; // Trigger an Error state
- }
-};
-
-const comparatorMap = {
- [Comparator.BETWEEN]: (value: number, [a, b]: number[]) =>
- value >= Math.min(a, b) && value <= Math.max(a, b),
- [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b,
- // `threshold` is always an array of numbers in case the BETWEEN/OUTSIDE_RANGE comparator is
- // used; all other compartors will just destructure the first value in the array
- [Comparator.GT]: (a: number, [b]: number[]) => a > b,
- [Comparator.LT]: (a: number, [b]: number[]) => a < b,
- [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b,
- [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
-};
export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) =>
- async function ({ services, params }: AlertExecutorOptions) {
- const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as {
- criteria: MetricExpressionParams[];
- groupBy: string | undefined | string[];
- filterQuery: string | undefined;
+ async function (options: AlertExecutorOptions) {
+ const { services, params } = options;
+ const { criteria } = params;
+ const { sourceId, alertOnNoData } = params as {
sourceId?: string;
alertOnNoData: boolean;
};
@@ -269,39 +31,18 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s
sourceId || 'default'
);
const config = source.configuration;
- const alertResults = await Promise.all(
- criteria.map((criterion) => {
- return (async () => {
- const currentValues = await getMetric(
- services,
- criterion,
- config.metricAlias,
- config.fields.timestamp,
- groupBy,
- filterQuery
- );
- const { threshold, comparator } = criterion;
- const comparisonFunction = comparatorMap[comparator];
- return mapValues(currentValues, (value) => ({
- ...criterion,
- metric: criterion.metric ?? DOCUMENT_COUNT_I18N,
- currentValue: value,
- shouldFire:
- value !== undefined && value !== null && comparisonFunction(value, threshold),
- isNoData: value === null,
- isError: value === undefined,
- }));
- })();
- })
- );
+ const alertResults = await evaluateAlert(services.callCluster, params, config);
- // Because each alert result has the same group definitions, just grap the groups from the first one.
+ // Because each alert result has the same group definitions, just grab the groups from the first one.
const groups = Object.keys(first(alertResults));
for (const group of groups) {
const alertInstance = services.alertInstanceFactory(`${alertId}-${group}`);
// AND logic; all criteria must be across the threshold
- const shouldAlertFire = alertResults.every((result) => result[group].shouldFire);
+ const shouldAlertFire = alertResults.every((result) =>
+ // Grab the result of the most recent bucket
+ last(result[group].shouldFire)
+ );
// AND logic; because we need to evaluate all criteria, if one of them reports no data then the
// whole alert is in a No Data/Error state
const isNoData = alertResults.some((result) => result[group].isNoData);
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts
new file mode 100644
index 00000000000000..7aa8367f7678ca
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts
@@ -0,0 +1,168 @@
+/*
+ * 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 { first, zip } from 'lodash';
+import {
+ TOO_MANY_BUCKETS_PREVIEW_EXCEPTION,
+ isTooManyBucketsPreviewException,
+} from '../../../../common/alerting/metrics';
+import { IScopedClusterClient } from '../../../../../../../src/core/server';
+import { InfraSource } from '../../../../common/http_api/source_api';
+import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
+import { MetricExpressionParams } from './types';
+import { evaluateAlert } from './lib/evaluate_alert';
+
+const MAX_ITERATIONS = 50;
+
+interface PreviewMetricThresholdAlertParams {
+ callCluster: IScopedClusterClient['callAsCurrentUser'];
+ params: {
+ criteria: MetricExpressionParams[];
+ groupBy: string | undefined | string[];
+ filterQuery: string | undefined;
+ };
+ config: InfraSource['configuration'];
+ lookback: 'h' | 'd' | 'w' | 'M';
+ alertInterval: string;
+ end?: number;
+ overrideLookbackIntervalInSeconds?: number;
+}
+
+export const previewMetricThresholdAlert: (
+ params: PreviewMetricThresholdAlertParams,
+ iterations?: number,
+ precalculatedNumberOfGroups?: number
+) => Promise> = async (
+ {
+ callCluster,
+ params,
+ config,
+ lookback,
+ alertInterval,
+ end = Date.now(),
+ overrideLookbackIntervalInSeconds,
+ },
+ iterations = 0,
+ precalculatedNumberOfGroups
+) => {
+ // There are three different "intervals" we're dealing with here, so to disambiguate:
+ // - The lookback interval, which is how long of a period of time we want to examine to count
+ // how many times the alert fired
+ // - The interval in the alert params, which we'll call the bucket interval; this is how large of
+ // a time bucket the alert uses to evaluate its result
+ // - The alert interval, which is how often the alert fires
+
+ const { timeSize, timeUnit } = params.criteria[0];
+ const bucketInterval = `${timeSize}${timeUnit}`;
+ const bucketIntervalInSeconds = getIntervalInSeconds(bucketInterval);
+
+ const lookbackInterval = `1${lookback}`;
+ const lookbackIntervalInSeconds =
+ overrideLookbackIntervalInSeconds ?? getIntervalInSeconds(lookbackInterval);
+
+ const start = end - lookbackIntervalInSeconds * 1000;
+ const timeframe = { start, end };
+
+ // Get a date histogram using the bucket interval and the lookback interval
+ try {
+ const alertResults = await evaluateAlert(callCluster, params, config, timeframe);
+ const groups = Object.keys(first(alertResults));
+
+ // Now determine how to interpolate this histogram based on the alert interval
+ const alertIntervalInSeconds = getIntervalInSeconds(alertInterval);
+ const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds;
+ const previewResults = await Promise.all(
+ groups.map(async (group) => {
+ const tooManyBuckets = alertResults.some((alertResult) =>
+ isTooManyBucketsPreviewException(alertResult[group])
+ );
+ if (tooManyBuckets) {
+ return TOO_MANY_BUCKETS_PREVIEW_EXCEPTION;
+ }
+
+ const isNoData = alertResults.some((alertResult) => alertResult[group].isNoData);
+ if (isNoData) {
+ return null;
+ }
+ const isError = alertResults.some((alertResult) => alertResult[group].isError);
+ if (isError) {
+ return NaN;
+ }
+
+ // Interpolate the buckets returned by evaluateAlert and return a count of how many of these
+ // buckets would have fired the alert. If the alert interval and bucket interval are the same,
+ // this will be a 1:1 evaluation of the alert results. If these are different, the interpolation
+ // will skip some buckets or read some buckets more than once, depending on the differential
+ const numberOfResultBuckets = first(alertResults)[group].shouldFire.length;
+ const numberOfExecutionBuckets = Math.floor(
+ numberOfResultBuckets / alertResultsPerExecution
+ );
+ let numberOfTimesFired = 0;
+ for (let i = 0; i < numberOfExecutionBuckets; i++) {
+ const mappedBucketIndex = Math.floor(i * alertResultsPerExecution);
+ const allConditionsFiredInMappedBucket = alertResults.every(
+ (alertResult) => alertResult[group].shouldFire[mappedBucketIndex]
+ );
+ if (allConditionsFiredInMappedBucket) numberOfTimesFired++;
+ }
+ return numberOfTimesFired;
+ })
+ );
+ return previewResults;
+ } catch (e) {
+ if (isTooManyBucketsPreviewException(e)) {
+ // If there's too much data on the first request, recursively slice the lookback interval
+ // until all the data can be retrieved
+ const basePreviewParams = { callCluster, params, config, lookback, alertInterval };
+ const { maxBuckets } = e;
+ // If this is still the first iteration, try to get the number of groups in order to
+ // calculate max buckets. If this fails, just estimate based on 1 group
+ const currentAlertResults = !precalculatedNumberOfGroups
+ ? await evaluateAlert(callCluster, params, config)
+ : [];
+ const numberOfGroups =
+ precalculatedNumberOfGroups ?? Math.max(Object.keys(first(currentAlertResults)).length, 1);
+ const estimatedTotalBuckets =
+ (lookbackIntervalInSeconds / bucketIntervalInSeconds) * numberOfGroups;
+ // The minimum number of slices is 2. In case we underestimate the total number of buckets
+ // in the first iteration, we can bisect the remaining buckets on further recursions to get
+ // all the data needed
+ const slices = Math.max(Math.ceil(estimatedTotalBuckets / maxBuckets), 2);
+ const slicedLookback = Math.floor(lookbackIntervalInSeconds / slices);
+
+ // Bail out if it looks like this is going to take too long
+ if (slicedLookback <= 0 || iterations > MAX_ITERATIONS || slices > MAX_ITERATIONS) {
+ return [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION];
+ }
+
+ const slicedRequests = [...Array(slices)].map((_, i) => {
+ return previewMetricThresholdAlert(
+ {
+ ...basePreviewParams,
+ end: Math.min(end, start + slicedLookback * (i + 1) * 1000),
+ overrideLookbackIntervalInSeconds: slicedLookback,
+ },
+ iterations + slices,
+ numberOfGroups
+ );
+ });
+ const results = await Promise.all(slicedRequests);
+ const zippedResult = zip(...results).map((result) =>
+ result
+ // `undefined` values occur if there is no data at all in a certain slice, and that slice
+ // returns an empty array. This is different from an error or no data state,
+ // so filter these results out entirely and only regard the resultA portion
+ .filter((value) => typeof value !== 'undefined')
+ .reduce((a, b) => {
+ if (typeof a !== 'number') return a;
+ if (typeof b !== 'number') return b;
+ return a + b;
+ })
+ );
+ return zippedResult;
+ } else throw e;
+ }
+};
diff --git a/x-pack/plugins/infra/server/routes/alerting/index.ts b/x-pack/plugins/infra/server/routes/alerting/index.ts
new file mode 100644
index 00000000000000..4ba2f56360f8a9
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/alerting/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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 * from './preview';
diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts
new file mode 100644
index 00000000000000..f4eed041481f6a
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 {
+ METRIC_THRESHOLD_ALERT_TYPE_ID,
+ METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID,
+ INFRA_ALERT_PREVIEW_PATH,
+ TOO_MANY_BUCKETS_PREVIEW_EXCEPTION,
+ alertPreviewRequestParamsRT,
+ alertPreviewSuccessResponsePayloadRT,
+ MetricThresholdAlertPreviewRequestParams,
+} from '../../../common/alerting/metrics';
+import { createValidationFunction } from '../../../common/runtime_types';
+import { previewMetricThresholdAlert } from '../../lib/alerting/metric_threshold/preview_metric_threshold_alert';
+import { InfraBackendLibs } from '../../lib/infra_types';
+
+export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => {
+ const { callWithRequest } = framework;
+ framework.registerRoute(
+ {
+ method: 'post',
+ path: INFRA_ALERT_PREVIEW_PATH,
+ validate: {
+ body: createValidationFunction(alertPreviewRequestParamsRT),
+ },
+ },
+ framework.router.handleLegacyErrors(async (requestContext, request, response) => {
+ const { criteria, filterQuery, lookback, sourceId, alertType, alertInterval } = request.body;
+
+ const callCluster = (endpoint: string, opts: Record) => {
+ return callWithRequest(requestContext, endpoint, opts);
+ };
+
+ const source = await sources.getSourceConfiguration(
+ requestContext.core.savedObjects.client,
+ sourceId || 'default'
+ );
+
+ try {
+ switch (alertType) {
+ case METRIC_THRESHOLD_ALERT_TYPE_ID: {
+ const { groupBy } = request.body as MetricThresholdAlertPreviewRequestParams;
+ const previewResult = await previewMetricThresholdAlert({
+ callCluster,
+ params: { criteria, filterQuery, groupBy },
+ lookback,
+ config: source.configuration,
+ alertInterval,
+ });
+
+ const numberOfGroups = previewResult.length;
+ const resultTotals = previewResult.reduce(
+ (totals, groupResult) => {
+ if (groupResult === TOO_MANY_BUCKETS_PREVIEW_EXCEPTION)
+ return { ...totals, tooManyBuckets: totals.tooManyBuckets + 1 };
+ if (groupResult === null) return { ...totals, noData: totals.noData + 1 };
+ if (isNaN(groupResult)) return { ...totals, error: totals.error + 1 };
+ return { ...totals, fired: totals.fired + groupResult };
+ },
+ {
+ fired: 0,
+ noData: 0,
+ error: 0,
+ tooManyBuckets: 0,
+ }
+ );
+
+ return response.ok({
+ body: alertPreviewSuccessResponsePayloadRT.encode({
+ numberOfGroups,
+ resultTotals,
+ }),
+ });
+ }
+ case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: {
+ // TODO: Add inventory preview functionality
+ return response.ok({});
+ }
+ default:
+ throw new Error('Unknown alert type');
+ }
+ } catch (error) {
+ return response.customError({
+ statusCode: error.statusCode ?? 500,
+ body: {
+ message: error.message ?? 'An unexpected error occurred',
+ },
+ });
+ }
+ })
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts
index 3309d8497f4c52..1fe29aa54f6f9a 100644
--- a/x-pack/plugins/ingest_manager/common/constants/routes.ts
+++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts
@@ -44,6 +44,7 @@ export const AGENT_CONFIG_API_ROUTES = {
INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`,
CREATE_PATTERN: `${AGENT_CONFIG_API_ROOT}`,
UPDATE_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`,
+ COPY_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/copy`,
DELETE_PATTERN: `${AGENT_CONFIG_API_ROOT}/delete`,
FULL_INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/full`,
FULL_INFO_DOWNLOAD_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/download`,
diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
index f0ed3ed9a0364d..ea61d97145795e 100644
--- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
+++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
@@ -48,13 +48,7 @@
"type": "boolean"
}
},
- "required": [
- "items",
- "total",
- "page",
- "perPage",
- "success"
- ]
+ "required": ["items", "total", "page", "perPage", "success"]
},
"examples": {
"success": {
@@ -66,14 +60,9 @@
"namespace": "default",
"description": "Default agent configuration created by Kibana",
"status": "active",
- "datasources": [
- "8a5679b0-8fbf-11ea-b2ce-01c4a6127154"
- ],
+ "datasources": ["8a5679b0-8fbf-11ea-b2ce-01c4a6127154"],
"is_default": true,
- "monitoring_enabled": [
- "logs",
- "metrics"
- ],
+ "monitoring_enabled": ["logs", "metrics"],
"revision": 2,
"updated_on": "2020-05-06T17:32:21.905Z",
"updated_by": "system",
@@ -175,10 +164,7 @@
"type": "boolean"
}
},
- "required": [
- "item",
- "success"
- ]
+ "required": ["item", "success"]
},
"examples": {
"success": {
@@ -213,21 +199,13 @@
"dataset": "system.auth",
"vars": {
"paths": {
- "value": [
- "/var/log/auth.log*",
- "/var/log/secure*"
- ],
+ "value": ["/var/log/auth.log*", "/var/log/secure*"],
"type": "text"
}
},
"agent_stream": {
- "paths": [
- "/var/log/auth.log*",
- "/var/log/secure*"
- ],
- "exclude_files": [
- ".gz$"
- ],
+ "paths": ["/var/log/auth.log*", "/var/log/secure*"],
+ "exclude_files": [".gz$"],
"multiline": {
"pattern": "^\\s",
"match": "after"
@@ -253,21 +231,13 @@
"dataset": "system.syslog",
"vars": {
"paths": {
- "value": [
- "/var/log/messages*",
- "/var/log/syslog*"
- ],
+ "value": ["/var/log/messages*", "/var/log/syslog*"],
"type": "text"
}
},
"agent_stream": {
- "paths": [
- "/var/log/messages*",
- "/var/log/syslog*"
- ],
- "exclude_files": [
- ".gz$"
- ],
+ "paths": ["/var/log/messages*", "/var/log/syslog*"],
+ "exclude_files": [".gz$"],
"multiline": {
"pattern": "^\\s",
"match": "after"
@@ -299,16 +269,12 @@
"dataset": "system.core",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "core"
- ],
+ "metricsets": ["core"],
"core.metrics": "percentages"
}
},
@@ -318,16 +284,11 @@
"dataset": "system.cpu",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -343,16 +304,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "cpu"
- ],
+ "metricsets": ["cpu"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -366,9 +323,7 @@
"enabled": true,
"dataset": "system.diskio",
"agent_stream": {
- "metricsets": [
- "diskio"
- ]
+ "metricsets": ["diskio"]
}
},
{
@@ -376,9 +331,7 @@
"enabled": true,
"dataset": "system.entropy",
"agent_stream": {
- "metricsets": [
- "entropy"
- ]
+ "metricsets": ["entropy"]
}
},
{
@@ -396,9 +349,7 @@
}
},
"agent_stream": {
- "metricsets": [
- "filesystem"
- ],
+ "metricsets": ["filesystem"],
"period": "1m",
"processors": [
{
@@ -424,9 +375,7 @@
}
},
"agent_stream": {
- "metricsets": [
- "fsstat"
- ],
+ "metricsets": ["fsstat"],
"period": "1m",
"processors": [
{
@@ -443,16 +392,11 @@
"dataset": "system.load",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -468,16 +412,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "load"
- ],
+ "metricsets": ["load"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -492,16 +432,11 @@
"dataset": "system.memory",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -517,16 +452,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "memory"
- ],
+ "metricsets": ["memory"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -541,16 +472,11 @@
"dataset": "system.network",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -566,16 +492,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "network"
- ],
+ "metricsets": ["network"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -589,9 +511,7 @@
"enabled": true,
"dataset": "system.network_summary",
"agent_stream": {
- "metricsets": [
- "network_summary"
- ]
+ "metricsets": ["network_summary"]
}
},
{
@@ -600,16 +520,11 @@
"dataset": "system.process",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -625,16 +540,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "process"
- ],
+ "metricsets": ["process"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -649,16 +560,11 @@
"dataset": "system.process_summary",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -674,16 +580,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "process_summary"
- ],
+ "metricsets": ["process_summary"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -697,9 +599,7 @@
"enabled": true,
"dataset": "system.raid",
"agent_stream": {
- "metricsets": [
- "raid"
- ]
+ "metricsets": ["raid"]
}
},
{
@@ -707,9 +607,7 @@
"enabled": true,
"dataset": "system.service",
"agent_stream": {
- "metricsets": [
- "service"
- ]
+ "metricsets": ["service"]
}
},
{
@@ -717,9 +615,7 @@
"enabled": true,
"dataset": "system.socket",
"agent_stream": {
- "metricsets": [
- "socket"
- ]
+ "metricsets": ["socket"]
}
},
{
@@ -728,16 +624,11 @@
"dataset": "system.socket_summary",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -753,16 +644,12 @@
"type": "integer"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "socket_summary"
- ],
+ "metricsets": ["socket_summary"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -777,16 +664,11 @@
"dataset": "system.uptime",
"vars": {
"core.metrics": {
- "value": [
- "percentages"
- ],
+ "value": ["percentages"],
"type": "text"
},
"cpu.metrics": {
- "value": [
- "percentages",
- "normalized_percentages"
- ],
+ "value": ["percentages", "normalized_percentages"],
"type": "text"
},
"period": {
@@ -794,16 +676,12 @@
"type": "text"
},
"processes": {
- "value": [
- ".*"
- ],
+ "value": [".*"],
"type": "text"
}
},
"agent_stream": {
- "metricsets": [
- "uptime"
- ],
+ "metricsets": ["uptime"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -815,9 +693,7 @@
"enabled": true,
"dataset": "system.users",
"agent_stream": {
- "metricsets": [
- "users"
- ]
+ "metricsets": ["users"]
}
}
]
@@ -827,10 +703,7 @@
}
],
"is_default": true,
- "monitoring_enabled": [
- "logs",
- "metrics"
- ],
+ "monitoring_enabled": ["logs", "metrics"],
"revision": 2,
"updated_on": "2020-05-06T17:32:21.905Z",
"updated_by": "system"
@@ -865,10 +738,7 @@
"type": "boolean"
}
},
- "required": [
- "item",
- "success"
- ]
+ "required": ["item", "success"]
},
"examples": {
"example-1": {
@@ -916,6 +786,64 @@
]
}
},
+ "/agent_configs/{agentConfigId}/copy": {
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "name": "agentConfigId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "post": {
+ "summary": "Agent config - copy one config",
+ "operationId": "agent-config-copy",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "item": {
+ "$ref": "#/components/schemas/AgentConfig"
+ },
+ "success": {
+ "type": "boolean"
+ }
+ },
+ "required": ["item", "success"]
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": ["name"]
+ },
+ "examples": {}
+ }
+ },
+ "description": ""
+ },
+ "description": "Copies one agent config"
+ }
+ },
"/agent_configs/delete": {
"post": {
"summary": "Agent Config - Delete",
@@ -937,10 +865,7 @@
"type": "boolean"
}
},
- "required": [
- "id",
- "success"
- ]
+ "required": ["id", "success"]
}
},
"examples": {
@@ -982,9 +907,7 @@
"examples": {
"example-1": {
"value": {
- "agentConfigIds": [
- "df7d2540-5a47-11ea-80da-89b5a66da347"
- ]
+ "agentConfigIds": ["df7d2540-5a47-11ea-80da-89b5a66da347"]
}
}
}
@@ -1030,10 +953,7 @@
"type": "boolean"
}
},
- "required": [
- "items",
- "success"
- ]
+ "required": ["items", "success"]
},
"examples": {
"example-1": {
@@ -1056,10 +976,7 @@
"type": "logs",
"streams": [
{
- "paths": [
- "/var/log/hello1.log",
- "/var/log/hello2.log"
- ]
+ "paths": ["/var/log/hello1.log", "/var/log/hello2.log"]
}
]
}
@@ -1098,16 +1015,12 @@
{
"enabled": true,
"dataset": "nginx.acccess",
- "paths": [
- "/var/log/nginx/access.log"
- ]
+ "paths": ["/var/log/nginx/access.log"]
},
{
"enabled": true,
"dataset": "nginx.error",
- "paths": [
- "/var/log/nginx/error.log"
- ]
+ "paths": ["/var/log/nginx/error.log"]
}
]
},
@@ -1141,16 +1054,12 @@
{
"enabled": true,
"dataset": "nginx.acccess",
- "paths": [
- "/var/log/nginx/access.log"
- ]
+ "paths": ["/var/log/nginx/access.log"]
},
{
"enabled": true,
"dataset": "nginx.error",
- "paths": [
- "/var/log/nginx/error.log"
- ]
+ "paths": ["/var/log/nginx/error.log"]
}
]
},
@@ -1184,16 +1093,12 @@
{
"enabled": true,
"dataset": "nginx.acccess",
- "paths": [
- "/var/log/nginx/access.log"
- ]
+ "paths": ["/var/log/nginx/access.log"]
},
{
"enabled": true,
"dataset": "nginx.error",
- "paths": [
- "/var/log/nginx/error.log"
- ]
+ "paths": ["/var/log/nginx/error.log"]
}
]
},
@@ -1224,16 +1129,12 @@
{
"streams": [
{
- "paths": [
- "/var/log/nginx/access.log"
- ],
+ "paths": ["/var/log/nginx/access.log"],
"dataset": "nginx.acccess",
"enabled": true
},
{
- "paths": [
- "/var/log/nginx/error.log"
- ],
+ "paths": ["/var/log/nginx/error.log"],
"dataset": "nginx.error",
"enabled": true
}
@@ -1302,16 +1203,12 @@
{
"enabled": true,
"dataset": "nginx.acccess",
- "paths": [
- "/var/log/nginx/access.log"
- ]
+ "paths": ["/var/log/nginx/access.log"]
},
{
"enabled": true,
"dataset": "nginx.error",
- "paths": [
- "/var/log/nginx/error.log"
- ]
+ "paths": ["/var/log/nginx/error.log"]
}
]
},
@@ -1359,10 +1256,7 @@
"type": "boolean"
}
},
- "required": [
- "item",
- "success"
- ]
+ "required": ["item", "success"]
}
}
}
@@ -1398,10 +1292,7 @@
"type": "boolean"
}
},
- "required": [
- "item",
- "sucess"
- ]
+ "required": ["item", "sucess"]
}
}
}
@@ -1430,9 +1321,7 @@
"type": "boolean"
}
},
- "required": [
- "isInitialized"
- ]
+ "required": ["isInitialized"]
},
"examples": {
"success": {
@@ -1472,9 +1361,7 @@
"type": "boolean"
}
},
- "required": [
- "isInitialized"
- ]
+ "required": ["isInitialized"]
},
"examples": {
"success": {
@@ -1500,10 +1387,7 @@
"type": "string"
}
},
- "required": [
- "admin_username",
- "admin_password"
- ]
+ "required": ["admin_username", "admin_password"]
}
}
}
@@ -1541,19 +1425,13 @@
"properties": {
"status": {
"type": "string",
- "enum": [
- "installed",
- "not_installed"
- ]
+ "enum": ["installed", "not_installed"]
},
"savedObject": {
"type": "string"
}
},
- "required": [
- "status",
- "savedObject"
- ]
+ "required": ["status", "savedObject"]
}
]
},
@@ -1567,10 +1445,7 @@
"readme": "/package/coredns-1.0.1/docs/README.md",
"description": "CoreDNS logs and metrics integration.\nThe CoreDNS integrations allows to gather logs and metrics from the CoreDNS DNS server to get better insights.\n",
"type": "integration",
- "categories": [
- "logs",
- "metrics"
- ],
+ "categories": ["logs", "metrics"],
"requirement": {
"kibana": {
"versions": ">6.7.0"
@@ -1703,16 +1578,12 @@
"ingest_pipeline": "pipeline-entry",
"vars": [
{
- "default": [
- "/var/log/coredns.log"
- ],
+ "default": ["/var/log/coredns.log"],
"name": "paths",
"type": "textarea"
},
{
- "default": [
- "coredns"
- ],
+ "default": ["coredns"],
"name": "tags",
"type": "text"
}
@@ -1726,9 +1597,7 @@
"type": "metrics",
"vars": [
{
- "default": [
- "http://localhost:9153"
- ],
+ "default": ["http://localhost:9153"],
"description": "CoreDNS hosts",
"name": "hosts",
"required": true
@@ -1889,20 +1758,14 @@
"type": "string"
}
},
- "required": [
- "id",
- "type"
- ]
+ "required": ["id", "type"]
}
},
"success": {
"type": "boolean"
}
},
- "required": [
- "response",
- "success"
- ]
+ "required": ["response", "success"]
}
}
}
@@ -1939,20 +1802,14 @@
"type": "string"
}
},
- "required": [
- "id",
- "type"
- ]
+ "required": ["id", "type"]
}
},
"success": {
"type": "boolean"
}
},
- "required": [
- "response",
- "success"
- ]
+ "required": ["response", "success"]
}
}
}
@@ -2668,11 +2525,7 @@
"type": "number"
}
},
- "required": [
- "id",
- "title",
- "count"
- ]
+ "required": ["id", "title", "count"]
}
}
}
@@ -2690,82 +2543,76 @@
"200": {
"description": "OK",
"content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "list": {
- "type": "array",
- "items": {
- "type": "object"
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "list": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "success": {
+ "type": "boolean"
+ },
+ "total": {
+ "type": "number"
+ },
+ "page": {
+ "type": "number"
+ },
+ "perPage": {
+ "type": "number"
}
},
- "success": {
- "type": "boolean"
- },
- "total": {
- "type": "number"
- },
- "page": {
- "type": "number"
- },
- "perPage": {
- "type": "number"
- }
+ "required": ["list", "success", "total", "page", "perPage"]
},
- "required": [
- "list",
- "success",
- "total",
- "page",
- "perPage"
- ]
- },
- "examples": {
- "example-1": {
- "value": {
- "list": [
- {
- "id": "205661d0-5e53-11ea-ad31-4f31c06bd9a4",
- "active": true,
- "config_id": "ae556400-5e39-11ea-8b49-f9747e466f7b",
- "type": "PERMANENT",
- "enrolled_at": "2020-03-04T20:02:50.605Z",
- "user_provided_metadata": {
- "dev_agent_version": "0.0.1",
- "region": "us-east"
- },
- "local_metadata": {
- "host": "localhost",
- "ip": "127.0.0.1",
- "system": "Darwin 18.7.0",
- "memory": 34359738368
- },
- "actions": [
- {
- "data": "{\"config\":{\"id\":\"ae556400-5e39-11ea-8b49-f9747e466f7b\",\"outputs\":{\"default\":{\"type\":\"elasticsearch\",\"hosts\":[\"http://localhost:9200\"],\"api_key\":\"\",\"api_token\":\"6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw\"}},\"datasources\":[]}}",
- "created_at": "2020-03-04T20:02:56.149Z",
- "id": "6a95c00a-d76d-4931-97c3-0bf935272d7d",
- "type": "CONFIG_CHANGE"
- }
- ],
- "access_api_key_id": "6Mkkp3ABz7e_XRqrzLNJ",
- "default_api_key": "6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw",
- "current_error_events": [],
- "last_checkin": "2020-03-04T20:03:05.700Z",
- "status": "online"
- }
- ],
- "success": true,
- "total": 1,
- "page": 1,
- "perPage": 20
+ "examples": {
+ "example-1": {
+ "value": {
+ "list": [
+ {
+ "id": "205661d0-5e53-11ea-ad31-4f31c06bd9a4",
+ "active": true,
+ "config_id": "ae556400-5e39-11ea-8b49-f9747e466f7b",
+ "type": "PERMANENT",
+ "enrolled_at": "2020-03-04T20:02:50.605Z",
+ "user_provided_metadata": {
+ "dev_agent_version": "0.0.1",
+ "region": "us-east"
+ },
+ "local_metadata": {
+ "host": "localhost",
+ "ip": "127.0.0.1",
+ "system": "Darwin 18.7.0",
+ "memory": 34359738368
+ },
+ "actions": [
+ {
+ "data": "{\"config\":{\"id\":\"ae556400-5e39-11ea-8b49-f9747e466f7b\",\"outputs\":{\"default\":{\"type\":\"elasticsearch\",\"hosts\":[\"http://localhost:9200\"],\"api_key\":\"\",\"api_token\":\"6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw\"}},\"datasources\":[]}}",
+ "created_at": "2020-03-04T20:02:56.149Z",
+ "id": "6a95c00a-d76d-4931-97c3-0bf935272d7d",
+ "type": "CONFIG_CHANGE"
+ }
+ ],
+ "access_api_key_id": "6Mkkp3ABz7e_XRqrzLNJ",
+ "default_api_key": "6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw",
+ "current_error_events": [],
+ "last_checkin": "2020-03-04T20:03:05.700Z",
+ "status": "online"
+ }
+ ],
+ "success": true,
+ "total": 1,
+ "page": 1,
+ "perPage": 20
+ }
}
}
}
}
}
- }
},
"operationId": "get-fleet-agents",
"security": [
@@ -2804,10 +2651,7 @@
"type": "string"
}
},
- "required": [
- "item",
- "success"
- ]
+ "required": ["item", "success"]
}
}
}
@@ -2880,9 +2724,7 @@
"properties": {
"action": {
"type": "string",
- "enum": [
- "checkin"
- ]
+ "enum": ["checkin"]
},
"success": {
"type": "string"
@@ -2909,13 +2751,7 @@
"type": "string"
}
},
- "required": [
- "agent_id",
- "data",
- "id",
- "created_at",
- "type"
- ]
+ "required": ["agent_id", "data", "id", "created_at", "type"]
}
}
}
@@ -2935,9 +2771,7 @@
"outputs": {
"default": {
"type": "elasticsearch",
- "hosts": [
- "http://localhost:9200"
- ],
+ "hosts": ["http://localhost:9200"],
"api_key": "Z-XkgHIBvwtjzIKtSCTh:AejRqdKpQx6z-6dqSI1LHg"
}
},
@@ -2957,13 +2791,8 @@
"id": "logs-system.auth",
"enabled": true,
"dataset": "system.auth",
- "paths": [
- "/var/log/auth.log*",
- "/var/log/secure*"
- ],
- "exclude_files": [
- ".gz$"
- ],
+ "paths": ["/var/log/auth.log*", "/var/log/secure*"],
+ "exclude_files": [".gz$"],
"multiline": {
"pattern": "^\\s",
"match": "after"
@@ -2986,13 +2815,8 @@
"id": "logs-system.syslog",
"enabled": true,
"dataset": "system.syslog",
- "paths": [
- "/var/log/messages*",
- "/var/log/syslog*"
- ],
- "exclude_files": [
- ".gz$"
- ],
+ "paths": ["/var/log/messages*", "/var/log/syslog*"],
+ "exclude_files": [".gz$"],
"multiline": {
"pattern": "^\\s",
"match": "after"
@@ -3021,18 +2845,14 @@
"id": "system/metrics-system.core",
"enabled": true,
"dataset": "system.core",
- "metricsets": [
- "core"
- ],
+ "metricsets": ["core"],
"core.metrics": "percentages"
},
{
"id": "system/metrics-system.cpu",
"enabled": true,
"dataset": "system.cpu",
- "metricsets": [
- "cpu"
- ],
+ "metricsets": ["cpu"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3044,25 +2864,19 @@
"id": "system/metrics-system.diskio",
"enabled": true,
"dataset": "system.diskio",
- "metricsets": [
- "diskio"
- ]
+ "metricsets": ["diskio"]
},
{
"id": "system/metrics-system.entropy",
"enabled": true,
"dataset": "system.entropy",
- "metricsets": [
- "entropy"
- ]
+ "metricsets": ["entropy"]
},
{
"id": "system/metrics-system.filesystem",
"enabled": true,
"dataset": "system.filesystem",
- "metricsets": [
- "filesystem"
- ],
+ "metricsets": ["filesystem"],
"period": "1m",
"processors": [
{
@@ -3076,9 +2890,7 @@
"id": "system/metrics-system.fsstat",
"enabled": true,
"dataset": "system.fsstat",
- "metricsets": [
- "fsstat"
- ],
+ "metricsets": ["fsstat"],
"period": "1m",
"processors": [
{
@@ -3092,9 +2904,7 @@
"id": "system/metrics-system.load",
"enabled": true,
"dataset": "system.load",
- "metricsets": [
- "load"
- ],
+ "metricsets": ["load"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3106,9 +2916,7 @@
"id": "system/metrics-system.memory",
"enabled": true,
"dataset": "system.memory",
- "metricsets": [
- "memory"
- ],
+ "metricsets": ["memory"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3120,9 +2928,7 @@
"id": "system/metrics-system.network",
"enabled": true,
"dataset": "system.network",
- "metricsets": [
- "network"
- ],
+ "metricsets": ["network"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3134,17 +2940,13 @@
"id": "system/metrics-system.network_summary",
"enabled": true,
"dataset": "system.network_summary",
- "metricsets": [
- "network_summary"
- ]
+ "metricsets": ["network_summary"]
},
{
"id": "system/metrics-system.process",
"enabled": true,
"dataset": "system.process",
- "metricsets": [
- "process"
- ],
+ "metricsets": ["process"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3156,9 +2958,7 @@
"id": "system/metrics-system.process_summary",
"enabled": true,
"dataset": "system.process_summary",
- "metricsets": [
- "process_summary"
- ],
+ "metricsets": ["process_summary"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3170,33 +2970,25 @@
"id": "system/metrics-system.raid",
"enabled": true,
"dataset": "system.raid",
- "metricsets": [
- "raid"
- ]
+ "metricsets": ["raid"]
},
{
"id": "system/metrics-system.service",
"enabled": true,
"dataset": "system.service",
- "metricsets": [
- "service"
- ]
+ "metricsets": ["service"]
},
{
"id": "system/metrics-system.socket",
"enabled": true,
"dataset": "system.socket",
- "metricsets": [
- "socket"
- ]
+ "metricsets": ["socket"]
},
{
"id": "system/metrics-system.socket_summary",
"enabled": true,
"dataset": "system.socket_summary",
- "metricsets": [
- "socket_summary"
- ],
+ "metricsets": ["socket_summary"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3208,9 +3000,7 @@
"id": "system/metrics-system.uptime",
"enabled": true,
"dataset": "system.uptime",
- "metricsets": [
- "uptime"
- ],
+ "metricsets": ["uptime"],
"core.metrics": "percentages",
"cpu.metrics": "percentages,normalized_percentages",
"period": "10s",
@@ -3220,9 +3010,7 @@
"id": "system/metrics-system.users",
"enabled": true,
"dataset": "system.users",
- "metricsets": [
- "users"
- ]
+ "metricsets": ["users"]
}
]
}
@@ -3373,15 +3161,10 @@
},
"action": {
"type": "string",
- "enum": [
- "acks"
- ]
+ "enum": ["acks"]
}
},
- "required": [
- "success",
- "action"
- ]
+ "required": ["success", "action"]
},
"examples": {
"success": {
@@ -3498,21 +3281,14 @@
"properties": {
"type": {
"type": "string",
- "enum": [
- "PERMANENT",
- "EPHEMERAL",
- "TEMPORARY"
- ]
+ "enum": ["PERMANENT", "EPHEMERAL", "TEMPORARY"]
},
"shared_id": {
"type": "string"
},
"metadata": {
"type": "object",
- "required": [
- "local",
- "user_provided"
- ],
+ "required": ["local", "user_provided"],
"properties": {
"local": {
"$ref": "#/components/schemas/AgentMetadata"
@@ -3523,10 +3299,7 @@
}
}
},
- "required": [
- "type",
- "metadata"
- ]
+ "required": ["type", "metadata"]
},
"examples": {
"good": {
@@ -3723,10 +3496,7 @@
},
"status": {
"type": "string",
- "enum": [
- "active",
- "inactive"
- ]
+ "enum": ["active", "inactive"]
},
"datasources": {
"oneOf": [
@@ -3757,10 +3527,7 @@
"type": "number"
}
},
- "required": [
- "id",
- "status"
- ]
+ "required": ["id", "status"]
}
]
},
@@ -3781,10 +3548,7 @@
"items": {}
}
},
- "required": [
- "id",
- "revision"
- ]
+ "required": ["id", "revision"]
},
{
"$ref": "#/components/schemas/NewDatasource"
@@ -3829,16 +3593,12 @@
{
"enabled": true,
"dataset": "nginx.acccess",
- "paths": [
- "/var/log/nginx/access.log"
- ]
+ "paths": ["/var/log/nginx/access.log"]
},
{
"enabled": true,
"dataset": "nginx.error",
- "paths": [
- "/var/log/nginx/error.log"
- ]
+ "paths": ["/var/log/nginx/error.log"]
}
]
},
@@ -3874,11 +3634,7 @@
"type": "string"
}
},
- "required": [
- "name",
- "version",
- "title"
- ]
+ "required": ["name", "version", "title"]
},
"namespace": {
"type": "string"
@@ -3914,11 +3670,7 @@
"type": "object"
}
},
- "required": [
- "type",
- "enabled",
- "streams"
- ]
+ "required": ["type", "enabled", "streams"]
}
},
"config_id": {
@@ -3931,12 +3683,7 @@
"type": "string"
}
},
- "required": [
- "output_id",
- "inputs",
- "config_id",
- "name"
- ]
+ "required": ["output_id", "inputs", "config_id", "name"]
},
"PackageInfo": {
"title": "PackageInfo",
@@ -4007,9 +3754,7 @@
"type": "string"
}
},
- "required": [
- "src"
- ]
+ "required": ["src"]
}
},
"icons": {
@@ -4059,10 +3804,7 @@
"type": "string"
}
},
- "required": [
- "name",
- "default"
- ]
+ "required": ["name", "default"]
}
},
"type": {
@@ -4072,14 +3814,7 @@
"type": "string"
}
},
- "required": [
- "title",
- "name",
- "release",
- "ingeset_pipeline",
- "type",
- "package"
- ]
+ "required": ["title", "name", "release", "ingeset_pipeline", "type", "package"]
}
},
"download": {
@@ -4153,13 +3888,7 @@
"AgentStatus": {
"type": "string",
"title": "AgentStatus",
- "enum": [
- "offline",
- "error",
- "online",
- "inactive",
- "warning"
- ]
+ "enum": ["offline", "error", "online", "inactive", "warning"]
},
"Agent": {
"title": "Agent",
@@ -4187,10 +3916,7 @@
"type": "string"
},
"config_revision": {
- "type": [
- "number",
- "null"
- ]
+ "type": ["number", "null"]
},
"config_newest_revision": {
"type": "number"
@@ -4223,23 +3949,12 @@
"type": "string"
}
},
- "required": [
- "type",
- "active",
- "enrolled_at",
- "id",
- "current_error_events",
- "status"
- ]
+ "required": ["type", "active", "enrolled_at", "id", "current_error_events", "status"]
},
"AgentType": {
"type": "string",
"title": "AgentType",
- "enum": [
- "PERMANENT",
- "EPHEMERAL",
- "TEMPORARY"
- ]
+ "enum": ["PERMANENT", "EPHEMERAL", "TEMPORARY"]
},
"AgentMetadata": {
"title": "AgentMetadata",
@@ -4251,12 +3966,7 @@
"properties": {
"type": {
"type": "string",
- "enum": [
- "STATE",
- "ERROR",
- "ACTION_RESULT",
- "ACTION"
- ]
+ "enum": ["STATE", "ERROR", "ACTION_RESULT", "ACTION"]
},
"subtype": {
"type": "string",
@@ -4295,13 +4005,7 @@
"type": "string"
}
},
- "required": [
- "type",
- "subtype",
- "timestamp",
- "message",
- "agent_id"
- ]
+ "required": ["type", "subtype", "timestamp", "message", "agent_id"]
},
"AgentEvent": {
"title": "AgentEvent",
@@ -4313,9 +4017,7 @@
"type": "string"
}
},
- "required": [
- "id"
- ]
+ "required": ["id"]
},
{
"$ref": "#/components/schemas/NewAgentEvent"
diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts
index 3fc990ea9d70ca..8136abe1a42d47 100644
--- a/x-pack/plugins/ingest_manager/common/services/routes.ts
+++ b/x-pack/plugins/ingest_manager/common/services/routes.ts
@@ -83,6 +83,10 @@ export const agentConfigRouteService = {
return AGENT_CONFIG_API_ROUTES.UPDATE_PATTERN.replace('{agentConfigId}', agentConfigId);
},
+ getCopyPath: (agentConfigId: string) => {
+ return AGENT_CONFIG_API_ROUTES.COPY_PATTERN.replace('{agentConfigId}', agentConfigId);
+ },
+
getDeletePath: () => {
return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN;
},
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts
index 82d7fa51b20826..86020cb5235ae6 100644
--- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts
@@ -49,6 +49,15 @@ export interface UpdateAgentConfigResponse {
success: boolean;
}
+export interface CopyAgentConfigRequest {
+ body: Pick;
+}
+
+export interface CopyAgentConfigResponse {
+ item: AgentConfig;
+ success: boolean;
+}
+
export interface DeleteAgentConfigRequest {
body: {
agentConfigId: string;
diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json
index 382ea0444093d4..35447139607a6b 100644
--- a/x-pack/plugins/ingest_manager/kibana.json
+++ b/x-pack/plugins/ingest_manager/kibana.json
@@ -5,5 +5,6 @@
"ui": true,
"configPath": ["xpack", "ingestManager"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects"],
- "optionalPlugins": ["security", "features", "cloud"]
+ "optionalPlugins": ["security", "features", "cloud"],
+ "extraPublicDirs": ["common"]
}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts
index 45ca6047b0d96d..c81303de3d7c3e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts
@@ -19,6 +19,8 @@ import {
CreateAgentConfigResponse,
UpdateAgentConfigRequest,
UpdateAgentConfigResponse,
+ CopyAgentConfigRequest,
+ CopyAgentConfigResponse,
DeleteAgentConfigRequest,
DeleteAgentConfigResponse,
} from '../../types';
@@ -76,6 +78,17 @@ export const sendUpdateAgentConfig = (
});
};
+export const sendCopyAgentConfig = (
+ agentConfigId: string,
+ body: CopyAgentConfigRequest['body']
+) => {
+ return sendRequest({
+ path: agentConfigRouteService.getCopyPath(agentConfigId),
+ method: 'post',
+ body: JSON.stringify(body),
+ });
+};
+
export const sendDeleteAgentConfig = (body: DeleteAgentConfigRequest['body']) => {
return sendRequest({
path: agentConfigRouteService.getDeletePath(),
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx
index dc61da685c88d6..39fe090e5008cf 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx
@@ -6,64 +6,97 @@
import React, { memo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiContextMenuItem, EuiPortal } from '@elastic/eui';
-import { useCapabilities, useLink } from '../../../hooks';
+import { AgentConfig } from '../../../types';
+import { useCapabilities } from '../../../hooks';
import { ContextMenuActions } from '../../../components';
+import { AgentEnrollmentFlyout } from '../../fleet/components';
import { ConfigYamlFlyout } from './config_yaml_flyout';
+import { AgentConfigCopyProvider } from './config_copy_provider';
-export const AgentConfigActionMenu = memo<{ configId: string; fullButton?: boolean }>(
- ({ configId, fullButton = false }) => {
- const { getHref } = useLink();
- const hasWriteCapabilities = useCapabilities().write;
- const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false);
- return (
- <>
- {isYamlFlyoutOpen ? (
-
- setIsYamlFlyoutOpen(false)} />
-
- ) : null}
-
- ),
- }
- : undefined
- }
- items={[
- setIsYamlFlyoutOpen(!isYamlFlyoutOpen)}
- key="viewConfig"
- >
-
- ,
-
-
- ,
- ]}
- />
- >
- );
- }
-);
+export const AgentConfigActionMenu = memo<{
+ config: AgentConfig;
+ onCopySuccess?: (newAgentConfig: AgentConfig) => void;
+ fullButton?: boolean;
+}>(({ config, onCopySuccess, fullButton = false }) => {
+ const hasWriteCapabilities = useCapabilities().write;
+ const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false);
+ const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false);
+
+ return (
+
+ {(copyAgentConfigPrompt) => {
+ return (
+ <>
+ {isYamlFlyoutOpen ? (
+
+ setIsYamlFlyoutOpen(false)} />
+
+ ) : null}
+ {isEnrollmentFlyoutOpen && (
+
+ setIsEnrollmentFlyoutOpen(false)}
+ />
+
+ )}
+
+ ),
+ }
+ : undefined
+ }
+ items={[
+ setIsEnrollmentFlyoutOpen(true)}
+ key="enrollAgents"
+ >
+
+ ,
+ setIsYamlFlyoutOpen(!isYamlFlyoutOpen)}
+ key="viewConfig"
+ >
+
+ ,
+ {
+ copyAgentConfigPrompt(config, onCopySuccess);
+ }}
+ key="copyConfig"
+ >
+
+ ,
+ ]}
+ />
+ >
+ );
+ }}
+
+ );
+});
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx
new file mode 100644
index 00000000000000..9776304797fd45
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx
@@ -0,0 +1,175 @@
+/*
+ * 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, { Fragment, useRef, useState } from 'react';
+import { EuiConfirmModal, EuiOverlayMask, EuiFormRow, EuiFieldText } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { AgentConfig } from '../../../types';
+import { sendCopyAgentConfig, useCore } from '../../../hooks';
+
+interface Props {
+ children: (copyAgentConfig: CopyAgentConfig) => React.ReactElement;
+}
+
+export type CopyAgentConfig = (agentConfig: AgentConfig, onSuccess?: OnSuccessCallback) => void;
+
+type OnSuccessCallback = (newAgentConfig: AgentConfig) => void;
+
+export const AgentConfigCopyProvider: React.FunctionComponent = ({ children }) => {
+ const { notifications } = useCore();
+ const [agentConfig, setAgentConfig] = useState();
+ const [newAgentConfig, setNewAgentConfig] = useState>();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const onSuccessCallback = useRef(null);
+
+ const copyAgentConfigPrompt: CopyAgentConfig = (
+ agentConfigToCopy,
+ onSuccess = () => undefined
+ ) => {
+ if (!agentConfigToCopy) {
+ throw new Error('No agent config specified to copy');
+ }
+ setIsModalOpen(true);
+ setAgentConfig(agentConfigToCopy);
+ setNewAgentConfig({
+ name: i18n.translate(
+ 'xpack.ingestManager.copyAgentConfig.confirmModal.defaultNewConfigName',
+ {
+ defaultMessage: '{name} (copy)',
+ values: { name: agentConfigToCopy.name },
+ }
+ ),
+ description: agentConfigToCopy.description,
+ });
+ onSuccessCallback.current = onSuccess;
+ };
+
+ const closeModal = () => {
+ setAgentConfig(undefined);
+ setNewAgentConfig(undefined);
+ setIsLoading(false);
+ setIsModalOpen(false);
+ };
+
+ const copyAgentConfig = async () => {
+ setIsLoading(true);
+ try {
+ const { data } = await sendCopyAgentConfig(agentConfig!.id, newAgentConfig!);
+
+ if (data?.success) {
+ notifications.toasts.addSuccess(
+ i18n.translate('xpack.ingestManager.copyAgentConfig.successNotificationTitle', {
+ defaultMessage: 'Agent config copied',
+ })
+ );
+ if (onSuccessCallback.current) {
+ onSuccessCallback.current(data.item);
+ }
+ }
+
+ if (!data?.success) {
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.ingestManager.copyAgentConfig.failureNotificationTitle', {
+ defaultMessage: "Error copying agent config '{id}'",
+ values: { id: agentConfig!.id },
+ })
+ );
+ }
+ } catch (e) {
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.ingestManager.copyAgentConfig.fatalErrorNotificationTitle', {
+ defaultMessage: 'Error copying agent config',
+ })
+ );
+ }
+ closeModal();
+ };
+
+ const renderModal = () => {
+ if (!isModalOpen || !agentConfig || !newAgentConfig) {
+ return null;
+ }
+
+ return (
+
+
+ }
+ onCancel={closeModal}
+ onConfirm={copyAgentConfig}
+ cancelButtonText={
+
+ }
+ confirmButtonText={
+
+ }
+ confirmButtonDisabled={isLoading || !newAgentConfig.name.trim()}
+ >
+
+
+
+
+ }
+ fullWidth
+ >
+ setNewAgentConfig({ ...newAgentConfig, name: e.target.value })}
+ />
+
+
+ }
+ fullWidth
+ >
+
+ setNewAgentConfig({ ...newAgentConfig, description: e.target.value })
+ }
+ />
+
+
+
+ );
+ };
+
+ return (
+
+ {children(copyAgentConfigPrompt)}
+ {renderModal()}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx
index 6fab78951038fa..410c0fcb2d140a 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useState } from 'react';
-import { Redirect, useRouteMatch, Switch, Route } from 'react-router-dom';
+import { Redirect, useRouteMatch, Switch, Route, useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedDate } from '@kbn/i18n/react';
import {
@@ -40,7 +40,8 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => {
const {
params: { configId, tabId = '' },
} = useRouteMatch<{ configId: string; tabId?: string }>();
- const { getHref } = useLink();
+ const history = useHistory();
+ const { getHref, getPath } = useLink();
const agentConfigRequest = useGetOneAgentConfig(configId);
const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null;
const { isLoading, error, sendRequest: refreshAgentConfig } = agentConfigRequest;
@@ -147,7 +148,15 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => {
},
{ isDivider: true },
{
- content: agentConfig && ,
+ content: agentConfig && (
+ {
+ history.push(getPath('configuration_details', { configId: newAgentConfig.id }));
+ }}
+ />
+ ),
},
].map((item, index) => (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
index 0d43d8856c2fba..8b1ff0988d4431 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
@@ -189,7 +189,9 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => {
}),
actions: [
{
- render: (config: AgentConfig) => ,
+ render: (config: AgentConfig) => (
+ sendRequest()} />
+ ),
},
],
},
@@ -201,7 +203,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => {
}
return cols;
- }, [getHref, isFleetEnabled]);
+ }, [getHref, isFleetEnabled, sendRequest]);
const createAgentConfigButton = useMemo(
() => (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx
index cc12ea19fbecfe..60cbc31081302f 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx
@@ -112,7 +112,7 @@ export const ListLayout: React.FunctionComponent<{}> = ({ children }) => {
setIsEnrollmentFlyoutOpen(true)}>
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts
index 05a97fd2e2a3c1..412bf412d1ef5a 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts
@@ -31,6 +31,8 @@ export {
CreateAgentConfigResponse,
UpdateAgentConfigRequest,
UpdateAgentConfigResponse,
+ CopyAgentConfigRequest,
+ CopyAgentConfigResponse,
DeleteAgentConfigRequest,
DeleteAgentConfigResponse,
// API schemas - Datasource
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
index afc146cf90447d..d01b361bd6ca44 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
@@ -15,6 +15,7 @@ import {
GetOneAgentConfigRequestSchema,
CreateAgentConfigRequestSchema,
UpdateAgentConfigRequestSchema,
+ CopyAgentConfigRequestSchema,
DeleteAgentConfigRequestSchema,
GetFullAgentConfigRequestSchema,
AgentConfig,
@@ -27,6 +28,7 @@ import {
GetOneAgentConfigResponse,
CreateAgentConfigResponse,
UpdateAgentConfigResponse,
+ CopyAgentConfigResponse,
DeleteAgentConfigResponse,
GetFullAgentConfigResponse,
} from '../../../common';
@@ -177,6 +179,34 @@ export const updateAgentConfigHandler: RequestHandler<
}
};
+export const copyAgentConfigHandler: RequestHandler<
+ TypeOf,
+ unknown,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ const user = await appContextService.getSecurity()?.authc.getCurrentUser(request);
+ try {
+ const agentConfig = await agentConfigService.copy(
+ soClient,
+ request.params.agentConfigId,
+ request.body,
+ {
+ user: user || undefined,
+ }
+ );
+ const body: CopyAgentConfigResponse = { item: agentConfig, success: true };
+ return response.ok({
+ body,
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
export const deleteAgentConfigsHandler: RequestHandler<
unknown,
unknown,
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts
index 4f6cfb436b93b4..95c7c13377366f 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts
@@ -10,6 +10,7 @@ import {
GetOneAgentConfigRequestSchema,
CreateAgentConfigRequestSchema,
UpdateAgentConfigRequestSchema,
+ CopyAgentConfigRequestSchema,
DeleteAgentConfigRequestSchema,
GetFullAgentConfigRequestSchema,
} from '../../types';
@@ -18,6 +19,7 @@ import {
getOneAgentConfigHandler,
createAgentConfigHandler,
updateAgentConfigHandler,
+ copyAgentConfigHandler,
deleteAgentConfigsHandler,
getFullAgentConfig,
downloadFullAgentConfig,
@@ -64,6 +66,16 @@ export const registerRoutes = (router: IRouter) => {
updateAgentConfigHandler
);
+ // Copy
+ router.post(
+ {
+ path: AGENT_CONFIG_API_ROUTES.COPY_PATTERN,
+ validate: CopyAgentConfigRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}-all`] },
+ },
+ copyAgentConfigHandler
+ );
+
// Delete
router.post(
{
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
index 678262ab6dcac3..4a877ef7de13b9 100644
--- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
@@ -179,6 +179,48 @@ class AgentConfigService {
return this._update(soClient, id, agentConfig, options?.user);
}
+ public async copy(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ newAgentConfigProps: Pick,
+ options?: { user?: AuthenticatedUser }
+ ): Promise {
+ // Copy base config
+ const baseAgentConfig = await this.get(soClient, id, true);
+ if (!baseAgentConfig) {
+ throw new Error('Agent config not found');
+ }
+ const { namespace, monitoring_enabled } = baseAgentConfig;
+ const newAgentConfig = await this.create(
+ soClient,
+ {
+ namespace,
+ monitoring_enabled,
+ ...newAgentConfigProps,
+ },
+ options
+ );
+
+ // Copy all datasources
+ if (baseAgentConfig.datasources.length) {
+ const newDatasources = (baseAgentConfig.datasources as Datasource[]).map(
+ (datasource: Datasource) => {
+ const { id: datasourceId, ...newDatasource } = datasource;
+ return newDatasource;
+ }
+ );
+ await datasourceService.bulkCreate(soClient, newDatasources, newAgentConfig.id, options);
+ }
+
+ // Get updated config
+ const updatedAgentConfig = await this.get(soClient, newAgentConfig.id, true);
+ if (!updatedAgentConfig) {
+ throw new Error('Copied agent config not found');
+ }
+
+ return updatedAgentConfig;
+ }
+
public async bumpRevision(
soClient: SavedObjectsClientContract,
id: string,
@@ -203,7 +245,6 @@ class AgentConfigService {
soClient,
id,
{
- ...oldAgentConfig,
datasources: uniq(
[...((oldAgentConfig.datasources || []) as string[])].concat(datasourceIds)
),
@@ -242,7 +283,8 @@ class AgentConfigService {
public async getDefaultAgentConfigId(soClient: SavedObjectsClientContract) {
const configs = await soClient.find({
type: AGENT_CONFIG_SAVED_OBJECT_TYPE,
- filter: `${AGENT_CONFIG_SAVED_OBJECT_TYPE}.attributes.is_default:true`,
+ searchFields: ['is_default'],
+ search: 'true',
});
if (configs.saved_objects.length === 0) {
diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts
index c559dac0c0dcd0..3ad94ea8191d4d 100644
--- a/x-pack/plugins/ingest_manager/server/services/datasource.ts
+++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts
@@ -56,6 +56,44 @@ class DatasourceService {
};
}
+ public async bulkCreate(
+ soClient: SavedObjectsClientContract,
+ datasources: NewDatasource[],
+ configId: string,
+ options?: { user?: AuthenticatedUser }
+ ): Promise {
+ const isoDate = new Date().toISOString();
+ const { saved_objects: newSos } = await soClient.bulkCreate>(
+ datasources.map((datasource) => ({
+ type: SAVED_OBJECT_TYPE,
+ attributes: {
+ ...datasource,
+ config_id: configId,
+ revision: 1,
+ created_at: isoDate,
+ created_by: options?.user?.username ?? 'system',
+ updated_at: isoDate,
+ updated_by: options?.user?.username ?? 'system',
+ },
+ }))
+ );
+
+ // Assign it to the given agent config
+ await agentConfigService.assignDatasources(
+ soClient,
+ configId,
+ newSos.map((newSo) => newSo.id),
+ {
+ user: options?.user,
+ }
+ );
+
+ return newSos.map((newSo) => ({
+ id: newSo.id,
+ ...newSo.attributes,
+ }));
+ }
+
public async get(soClient: SavedObjectsClientContract, id: string): Promise {
const datasourceSO = await soClient.get(SAVED_OBJECT_TYPE, id);
if (!datasourceSO) {
diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts
index b70578efe468c0..b4af2310243701 100644
--- a/x-pack/plugins/ingest_manager/server/services/output.ts
+++ b/x-pack/plugins/ingest_manager/server/services/output.ts
@@ -14,11 +14,16 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE;
let cachedAdminUser: null | { username: string; password: string } = null;
class OutputService {
- public async ensureDefaultOutput(soClient: SavedObjectsClientContract) {
- const outputs = await soClient.find |