diff --git a/x-pack/plugins/alerting/common/alert_instance_summary.ts b/x-pack/plugins/alerting/common/alert_instance_summary.ts index 7ae37d6ddf53a5..462d20b8fb2ea8 100644 --- a/x-pack/plugins/alerting/common/alert_instance_summary.ts +++ b/x-pack/plugins/alerting/common/alert_instance_summary.ts @@ -23,6 +23,10 @@ export interface AlertInstanceSummary { lastRun?: string; errorMessages: Array<{ date: string; message: string }>; instances: Record; + executionDuration: { + average: number; + values: number[]; + }; } export interface AlertInstanceStatus { diff --git a/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.test.ts b/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.test.ts index 22122b22fd3bbb..a6529f4c30a7b5 100644 --- a/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { random, mean } from 'lodash'; import { SanitizedAlert, AlertInstanceSummary } from '../types'; import { IValidatedEvent } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER, LEGACY_EVENT_LOG_ACTIONS } from '../plugin'; @@ -31,6 +32,10 @@ describe('alertInstanceSummaryFromEventLog', () => { "consumer": "alert-consumer", "enabled": false, "errorMessages": Array [], + "executionDuration": Object { + "average": 0, + "values": Array [], + }, "id": "alert-123", "instances": Object {}, "lastRun": undefined, @@ -71,6 +76,10 @@ describe('alertInstanceSummaryFromEventLog', () => { "consumer": "alert-consumer-2", "enabled": true, "errorMessages": Array [], + "executionDuration": Object { + "average": 0, + "values": Array [], + }, "id": "alert-456", "instances": Object {}, "lastRun": undefined, @@ -137,7 +146,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object {}, @@ -145,6 +154,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "OK", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('active alert with no instances but has errors', async () => { @@ -163,7 +174,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, errorMessages, instances } = summary; + const { lastRun, status, errorMessages, instances, executionDuration } = summary; expect({ lastRun, status, errorMessages, instances }).toMatchInlineSnapshot(` Object { "errorMessages": Array [ @@ -181,6 +192,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Error", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently inactive instance', async () => { @@ -202,7 +215,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -218,6 +231,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "OK", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('legacy alert with currently inactive instance', async () => { @@ -239,7 +254,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -255,6 +270,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "OK", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently inactive instance, no new-instance', async () => { @@ -275,7 +292,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -291,6 +308,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "OK", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently active instance', async () => { @@ -312,7 +331,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -328,6 +347,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently active instance with no action group in event log', async () => { @@ -349,7 +370,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -365,6 +386,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently active instance that switched action groups', async () => { @@ -386,7 +409,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -402,6 +425,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with currently active instance, no new-instance', async () => { @@ -422,7 +447,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -438,6 +463,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with active and inactive muted alerts', async () => { @@ -462,7 +489,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -485,6 +512,8 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); test('alert with active and inactive alerts over many executes', async () => { @@ -515,7 +544,7 @@ describe('alertInstanceSummaryFromEventLog', () => { dateEnd, }); - const { lastRun, status, instances } = summary; + const { lastRun, status, instances, executionDuration } = summary; expect({ lastRun, status, instances }).toMatchInlineSnapshot(` Object { "instances": Object { @@ -538,7 +567,19 @@ describe('alertInstanceSummaryFromEventLog', () => { "status": "Active", } `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); + + const testExecutionDurations = ( + actualDurations: number[], + executionDuration?: { average?: number; values?: number[] } + ) => { + expect(executionDuration).toEqual({ + average: Math.round(mean(actualDurations)), + values: actualDurations, + }); + }; }); function dateString(isoBaseDate: string, offsetMillis = 0): string { @@ -573,6 +614,7 @@ export class EventsFactory { event: { provider: EVENT_LOG_PROVIDER, action: EVENT_LOG_ACTIONS.execute, + duration: random(2000, 180000) * 1000 * 1000, }, }; @@ -634,6 +676,12 @@ export class EventsFactory { }); return this; } + + getExecutionDurations(): number[] { + return this.events + .filter((ev) => ev?.event?.action === 'execute' && ev?.event?.duration !== undefined) + .map((ev) => ev?.event?.duration! / (1000 * 1000)); + } } function createAlert(overrides: Partial): SanitizedAlert<{ bar: boolean }> { diff --git a/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.ts b/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.ts index 8a6c55dfac619c..40fae121df51dc 100644 --- a/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.ts +++ b/x-pack/plugins/alerting/server/lib/alert_instance_summary_from_event_log.ts @@ -5,10 +5,13 @@ * 2.0. */ +import { mean } from 'lodash'; import { SanitizedAlert, AlertInstanceSummary, AlertInstanceStatus } from '../types'; import { IEvent } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER, LEGACY_EVENT_LOG_ACTIONS } from '../plugin'; +const Millis2Nanos = 1000 * 1000; + export interface AlertInstanceSummaryFromEventLogParams { alert: SanitizedAlert<{ bar: boolean }>; events: IEvent[]; @@ -36,9 +39,14 @@ export function alertInstanceSummaryFromEventLog( lastRun: undefined, errorMessages: [], instances: {}, + executionDuration: { + average: 0, + values: [], + }, }; const instances = new Map(); + const eventDurations: number[] = []; // loop through the events // should be sorted newest to oldest, we want oldest to newest, so reverse @@ -66,6 +74,10 @@ export function alertInstanceSummaryFromEventLog( alertInstanceSummary.status = 'OK'; } + if (event?.event?.duration) { + eventDurations.push(event?.event?.duration / Millis2Nanos); + } + continue; } @@ -111,6 +123,13 @@ export function alertInstanceSummaryFromEventLog( alertInstanceSummary.errorMessages.sort((a, b) => a.date.localeCompare(b.date)); + if (eventDurations.length > 0) { + alertInstanceSummary.executionDuration = { + average: Math.round(mean(eventDurations)), + values: eventDurations, + }; + } + return alertInstanceSummary; } diff --git a/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.test.ts index f0f34ae5756b48..5044c5c8617a03 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.test.ts @@ -38,6 +38,10 @@ describe('getRuleAlertSummaryRoute', () => { status: 'OK', errorMessages: [], instances: {}, + executionDuration: { + average: 1, + values: [3, 5, 5], + }, }; it('gets rule alert summary', async () => { diff --git a/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.ts b/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.ts index 5bb8c5efd0d79b..131bddcce049db 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_alert_summary.ts @@ -39,6 +39,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ errorMessages, lastRun, instances: alerts, + executionDuration, ...rest }) => ({ ...rest, @@ -49,6 +50,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ status_end_date: statusEndDate, error_messages: errorMessages, last_run: lastRun, + execution_duration: executionDuration, }); export const getRuleAlertSummaryRoute = ( diff --git a/x-pack/plugins/alerting/server/routes/legacy/get_alert_instance_summary.test.ts b/x-pack/plugins/alerting/server/routes/legacy/get_alert_instance_summary.test.ts index d529aec4162d6d..ed2b3056da45e9 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/get_alert_instance_summary.test.ts @@ -43,6 +43,10 @@ describe('getAlertInstanceSummaryRoute', () => { status: 'OK', errorMessages: [], instances: {}, + executionDuration: { + average: 0, + values: [], + }, }; it('gets alert instance summary', async () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts index 28e432a78d6f98..2d8af69781c3b7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit, mean } from 'lodash'; import { RulesClient, ConstructorOptions } from '../rules_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { taskManagerMock } from '../../../../task_manager/server/mocks'; @@ -138,8 +139,11 @@ describe('getAlertInstanceSummary()', () => { const dateStart = new Date(Date.now() - 60 * 1000).toISOString(); + const durations: number[] = eventsFactory.getExecutionDurations(); + const result = await rulesClient.getAlertInstanceSummary({ id: '1', dateStart }); - expect(result).toMatchInlineSnapshot(` + const resultWithoutExecutionDuration = omit(result, 'executionDuration'); + expect(resultWithoutExecutionDuration).toMatchInlineSnapshot(` Object { "alertTypeId": "123", "consumer": "alert-consumer", @@ -182,6 +186,11 @@ describe('getAlertInstanceSummary()', () => { "throttle": null, } `); + + expect(result.executionDuration).toEqual({ + average: Math.round(mean(durations)), + values: durations, + }); }); // Further tests don't check the result of `getAlertInstanceSummary()`, as the result diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts index c7b987f2b04bdf..9f62e0ed0d50e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts @@ -28,6 +28,10 @@ describe('loadAlertInstanceSummary', () => { statusStartDate: '2021-04-01T21:19:25.174Z', tags: [], throttle: null, + executionDuration: { + average: 0, + values: [], + }, }; http.get.mockResolvedValueOnce({ @@ -45,6 +49,10 @@ describe('loadAlertInstanceSummary', () => { status_start_date: '2021-04-01T21:19:25.174Z', tags: [], throttle: null, + execution_duration: { + average: 0, + values: [], + }, }); const result = await loadAlertInstanceSummary({ http, alertId: 'te/st' }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts index cb924db74cea55..701c8f3a7beec8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts @@ -17,6 +17,7 @@ const rewriteBodyRes: RewriteRequestCase = ({ status_end_date: statusEndDate, error_messages: errorMessages, last_run: lastRun, + execution_duration: executionDuration, ...rest }: any) => ({ ...rest, @@ -27,6 +28,7 @@ const rewriteBodyRes: RewriteRequestCase = ({ errorMessages, lastRun, instances: alerts, + executionDuration, }); export async function loadAlertInstanceSummary({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.test.ts new file mode 100644 index 00000000000000..da102405c179d6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatMillisForDisplay, shouldShowDurationWarning } from './execution_duration_utils'; +import { AlertType as RuleType } from '../../types'; + +describe('formatMillisForDisplay', () => { + it('should return 0 for undefined', () => { + expect(formatMillisForDisplay(undefined)).toEqual('00:00:00.000'); + }); + + it('should correctly format millisecond duration in milliseconds', () => { + expect(formatMillisForDisplay(845)).toEqual('00:00:00.845'); + }); + + it('should correctly format second duration in milliseconds', () => { + expect(formatMillisForDisplay(45200)).toEqual('00:00:45.200'); + }); + + it('should correctly format minute duration in milliseconds', () => { + expect(formatMillisForDisplay(122450)).toEqual('00:02:02.450'); + }); + + it('should correctly format hour duration in milliseconds', () => { + expect(formatMillisForDisplay(3634601)).toEqual('01:00:34.601'); + }); +}); + +describe('shouldShowDurationWarning', () => { + it('should return false if rule type or ruleTaskTimeout is undefined', () => { + expect(shouldShowDurationWarning(undefined, 1000)).toEqual(false); + expect(shouldShowDurationWarning(mockRuleType(), 1000)).toEqual(false); + }); + + it('should return false if average duration is less than rule task timeout', () => { + expect(shouldShowDurationWarning(mockRuleType({ ruleTaskTimeout: '1m' }), 1000)).toEqual(false); + }); + + it('should return true if average duration is greater than rule task timeout', () => { + expect(shouldShowDurationWarning(mockRuleType({ ruleTaskTimeout: '1m' }), 120000)).toEqual( + true + ); + }); +}); + +function mockRuleType(overwrites: Partial = {}): RuleType { + return { + id: 'test.testRuleType', + name: 'My Test Rule Type', + actionGroups: [{ id: 'default', name: 'Default Action Group' }], + actionVariables: { + context: [], + state: [], + params: [], + }, + defaultActionGroupId: 'default', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + authorizedConsumers: {}, + producer: 'alerts', + minimumLicenseRequired: 'basic', + enabledInLicense: true, + ...overwrites, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.ts new file mode 100644 index 00000000000000..ecad5475274cea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/execution_duration_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { padStart } from 'lodash'; +import { AlertType as RuleType } from '../../types'; +import { parseDuration } from '../../../../alerting/common'; + +export function formatMillisForDisplay(value: number | undefined) { + if (!value) { + return '00:00:00.000'; + } + + const duration = moment.duration(value); + const durationString = [duration.hours(), duration.minutes(), duration.seconds()] + .map((v: number) => padStart(`${v}`, 2, '0')) + .join(':'); + + // add millis + const millisString = padStart(`${duration.milliseconds()}`, 3, '0'); + return `${durationString}.${millisString}`; +} + +export function shouldShowDurationWarning( + ruleType: RuleType | undefined, + avgDurationMillis: number +) { + if (!ruleType || !ruleType.ruleTaskTimeout) { + return false; + } + const ruleTypeTimeout: string = ruleType.ruleTaskTimeout; + const ruleTypeTimeoutMillis: number = parseDuration(ruleTypeTimeout); + return avgDurationMillis > ruleTypeTimeoutMillis; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.scss index 76fd94fd4044b1..454d6e20d9323e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.scss @@ -9,3 +9,8 @@ width: 85%; } } + +.ruleDurationWarningIcon { + bottom: $euiSizeXS; + position: relative; +} \ No newline at end of file diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index cada7748062f15..a790427e364835 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -8,9 +8,13 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; import { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances'; import { Alert, AlertInstanceSummary, AlertInstanceStatus, AlertType } from '../../../../types'; import { EuiBasicTable } from '@elastic/eui'; +import { ExecutionDurationChart } from '../../common/components/execution_duration_chart'; + jest.mock('../../../../common/lib/kibana'); const fakeNow = new Date('2020-02-09T23:15:41.941Z'); @@ -271,6 +275,118 @@ describe('alertInstanceToListItem', () => { }); }); +describe('execution duration overview', () => { + it('render last execution status', async () => { + const rule = mockAlert({ + executionStatus: { status: 'ok', lastExecutionDate: new Date('2020-08-20T19:23:38Z') }, + }); + const ruleType = mockAlertType(); + const alertSummary = mockAlertInstanceSummary(); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const ruleExecutionStatusStat = wrapper.find('[data-test-subj="ruleStatus-ok"]'); + expect(ruleExecutionStatusStat.exists()).toBeTruthy(); + expect(ruleExecutionStatusStat.first().prop('description')).toEqual('Last response'); + expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-ok"]').text()).toEqual('Ok'); + }); + + it('renders average execution duration', async () => { + const rule = mockAlert(); + const ruleType = mockAlertType({ ruleTaskTimeout: '10m' }); + const alertSummary = mockAlertInstanceSummary({ + executionDuration: { average: 60284, values: [] }, + }); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); + expect(avgExecutionDurationPanel.exists()).toBeTruthy(); + expect(avgExecutionDurationPanel.first().prop('color')).toEqual('subdued'); + expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + 'Average duration00:01:00.284' + ); + expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeFalsy(); + }); + + it('renders warning when average execution duration exceeds rule timeout', async () => { + const rule = mockAlert(); + const ruleType = mockAlertType({ ruleTaskTimeout: '10m' }); + const alertSummary = mockAlertInstanceSummary({ + executionDuration: { average: 60284345, values: [] }, + }); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); + expect(avgExecutionDurationPanel.exists()).toBeTruthy(); + expect(avgExecutionDurationPanel.first().prop('color')).toEqual('warning'); + expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + 'Average duration16:44:44.345' + ); + expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeTruthy(); + }); + + it('renders execution duration chart', () => { + const rule = mockAlert(); + const ruleType = mockAlertType(); + const alertSummary = mockAlertInstanceSummary(); + + expect( + shallow( + + ) + .find(ExecutionDurationChart) + .exists() + ).toBeTruthy(); + }); +}); + function mockAlert(overloads: Partial = {}): Alert { return { id: uuid.v4(), @@ -342,6 +458,10 @@ function mockAlertInstanceSummary( actionGroupId: 'testActionGroup', }, }, + executionDuration: { + average: 0, + values: [], + }, }; return { ...summary, ...overloads }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx index 1583cb188f1c18..9de70699edbdbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -8,11 +8,26 @@ import React, { useState } from 'react'; import moment, { Duration } from 'moment'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, EuiHealth, EuiSpacer, EuiToolTip } from '@elastic/eui'; +import { + EuiBasicTable, + EuiHealth, + EuiSpacer, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiStat, + EuiIconTip, +} from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; import { padStart, chunk } from 'lodash'; -import { ActionGroup, AlertInstanceStatusValues } from '../../../../../../alerting/common'; +import { + ActionGroup, + AlertExecutionStatusErrorReasons, + AlertInstanceStatusValues, +} from '../../../../../../alerting/common'; import { Alert, AlertInstanceSummary, @@ -27,6 +42,16 @@ import { import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import './alert_instances.scss'; import { RuleMutedSwitch } from './rule_muted_switch'; +import { getHealthColor } from '../../alerts_list/components/alert_status_filter'; +import { + alertsStatusesTranslationsMapping, + ALERT_STATUS_LICENSE_ERROR, +} from '../../alerts_list/translations'; +import { + formatMillisForDisplay, + shouldShowDurationWarning, +} from '../../../lib/execution_duration_utils'; +import { ExecutionDurationChart } from '../../common/components/execution_duration_chart'; type AlertInstancesProps = { alert: Alert; @@ -161,8 +186,91 @@ export function AlertInstances({ requestRefresh(); }; + const showDurationWarning = shouldShowDurationWarning( + alertType, + alertInstanceSummary.executionDuration.average + ); + + const healthColor = getHealthColor(alert.executionStatus.status); + const isLicenseError = + alert.executionStatus.error?.reason === AlertExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : alertsStatusesTranslationsMapping[alert.executionStatus.status]; + return ( <> + + + + + + {statusMessage} + + } + description={i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.ruleLastExecutionDescription', + { + defaultMessage: `Last response`, + } + )} + /> + + + + + + {showDurationWarning && ( + + + + )} + + {formatMillisForDisplay(alertInstanceSummary.executionDuration.average)} + + + } + description={i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.avgDurationDescription', + { + defaultMessage: `Average duration`, + } + )} + /> + + + + + + = {}): any { muted: false, }, }, + executionDuration: { + average: 0, + values: [], + }, }; return summary; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 91b1b14083ed2f..a7d54a896d7ef7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -8,7 +8,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { i18n } from '@kbn/i18n'; -import { capitalize, padStart, sortBy } from 'lodash'; +import { capitalize, sortBy } from 'lodash'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useEffect, useState } from 'react'; @@ -72,7 +72,6 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, formatDuration, - parseDuration, } from '../../../../../../alerting/common'; import { alertsStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; @@ -82,6 +81,10 @@ import { CenterJustifiedSpinner } from '../../../components/center_justified_spi import { ManageLicenseModal } from './manage_license_modal'; import { checkAlertTypeEnabled } from '../../../lib/check_alert_type_enabled'; import { RuleEnabledSwitch } from './rule_enabled_switch'; +import { + formatMillisForDisplay, + shouldShowDurationWarning, +} from '../../../lib/execution_duration_utils'; const ENTER_KEY = 13; @@ -523,25 +526,14 @@ export const AlertsList: React.FunctionComponent = () => { truncateText: false, 'data-test-subj': 'alertsTableCell-duration', render: (value: number, item: AlertTableItem) => { - const ruleTypeTimeout: string | undefined = alertTypesState.data.get( - item.alertTypeId - )?.ruleTaskTimeout; - const ruleTypeTimeoutMillis: number | undefined = ruleTypeTimeout - ? parseDuration(ruleTypeTimeout) - : undefined; - const showDurationWarning: boolean = ruleTypeTimeoutMillis - ? value > ruleTypeTimeoutMillis - : false; - const duration = moment.duration(value); - const durationString = [duration.hours(), duration.minutes(), duration.seconds()] - .map((v: number) => padStart(`${v}`, 2, '0')) - .join(':'); + const showDurationWarning = shouldShowDurationWarning( + alertTypesState.data.get(item.alertTypeId), + value + ); - // add millis - const millisString = padStart(`${duration.milliseconds()}`, 3, '0'); return ( <> - {`${durationString}.${millisString}`} + {`${formatMillisForDisplay(value)}`} {showDurationWarning && ( { + it('renders empty state when no execution duration values are available', async () => { + const executionDuration = mockExecutionDuration(); + + const wrapper = mountWithIntl(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionDurationChartEmpty"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionDurationChart"]').exists()).toBeFalsy(); + }); + + it('renders chart when execution duration values are available', async () => { + const executionDuration = mockExecutionDuration({ average: 10, values: [1, 2] }); + + const wrapper = mountWithIntl(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionDurationChartEmpty"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="executionDurationChart"]').exists()).toBeTruthy(); + }); +}); + +describe('padOrTruncateDurations', () => { + it('does nothing when array is the correct length', () => { + expect(padOrTruncateDurations([1, 2, 3], 3)).toEqual([1, 2, 3]); + }); + + it('pads execution duration values when there are fewer than display desires', () => { + expect(padOrTruncateDurations([1, 2, 3], 10)).toEqual([1, 2, 3, 0, 0, 0, 0, 0, 0, 0]); + }); + + it('truncates execution duration values when there are more than display desires', () => { + expect(padOrTruncateDurations([1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13], 10)).toEqual([ + 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, + ]); + }); +}); + +function mockExecutionDuration( + overwrites: { + average?: number; + values?: number[]; + } = {} +) { + return { + average: 0, + values: [], + ...overwrites, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/execution_duration_chart.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/execution_duration_chart.tsx new file mode 100644 index 00000000000000..ca20af9cea0fd8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/execution_duration_chart.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + EuiIconTip, + EuiTitle, +} from '@elastic/eui'; +import lightEuiTheme from '@elastic/eui/dist/eui_theme_light.json'; +import { Axis, BarSeries, Chart, CurveType, LineSeries, Settings } from '@elastic/charts'; +import { assign, fill } from 'lodash'; +import { formatMillisForDisplay } from '../../../lib/execution_duration_utils'; + +export interface ComponentOpts { + executionDuration: { + average: number; + values: number[]; + }; +} + +const DESIRED_NUM_EXECUTION_DURATIONS = 30; + +export const ExecutionDurationChart: React.FunctionComponent = ({ + executionDuration, +}: ComponentOpts) => { + const paddedExecutionDurations = padOrTruncateDurations( + executionDuration.values, + DESIRED_NUM_EXECUTION_DURATIONS + ); + + return ( + + + + +

+ +

+
+
+ + + +
+ + {executionDuration.values && executionDuration.values.length > 0 ? ( + <> + + + [ndx, val])} + /> + [ndx, executionDuration.average])} + curve={CurveType.CURVE_NATURAL} + /> + formatMillisForDisplay(d)} /> + + + ) : ( + <> + +

+ +

+ + } + /> + + )} +
+ ); +}; + +export function padOrTruncateDurations(values: number[], desiredSize: number) { + if (values.length === desiredSize) { + return values; + } else if (values.length < desiredSize) { + return assign(fill(new Array(desiredSize), 0), values); + } else { + // oldest durations are at the start of the array, so take the last {desiredSize} values + return values.slice(-desiredSize); + } +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_instance_summary.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_instance_summary.ts index d4ca6c2aa9cd89..181e6cc940c388 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_instance_summary.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_instance_summary.ts @@ -73,6 +73,7 @@ export default function createGetAlertInstanceSummaryTests({ getService }: FtrPr 'status_start_date', 'status_end_date', 'last_run', + 'execution_duration', ]); expect(stableBody).to.eql({ name: 'abc', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_instance_summary.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_instance_summary.ts index 099502e375faa1..ff55c376c04b2f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_instance_summary.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_instance_summary.ts @@ -58,7 +58,12 @@ export default function createGetAlertInstanceSummaryTests({ getService }: FtrPr const { status_start_date: statusStartDate, status_end_date: statusEndDate } = response.body; expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); - const stableBody = omit(response.body, ['status_start_date', 'status_end_date', 'last_run']); + const stableBody = omit(response.body, [ + 'status_start_date', + 'status_end_date', + 'last_run', + 'execution_duration', + ]); expect(stableBody).to.eql({ id: createdAlert.id, name: 'abc', @@ -91,7 +96,12 @@ export default function createGetAlertInstanceSummaryTests({ getService }: FtrPr const { status_start_date: statusStartDate, status_end_date: statusEndDate } = response.body; expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); - const stableBody = omit(response.body, ['status_start_date', 'status_end_date', 'last_run']); + const stableBody = omit(response.body, [ + 'status_start_date', + 'status_end_date', + 'last_run', + 'execution_duration', + ]); expect(stableBody).to.eql({ id: createdAlert.id, name: 'abc',