From ab082647acf9546d75beb47cd2cea31c91392346 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Thu, 10 Dec 2020 15:51:52 -0500 Subject: [PATCH] [Actions] Notify only on action group change (#82969) * plugged Task Manager lifecycle into status reactively * fixed tests * Revert "fixed tests" This reverts commit e9f2cd05bd93cf231ae0bb6e886221c95b43100c. * made action group fields optional * revert deletion * again * extracted action type for mto its own component * extracted more sections of the action form to their own components * updated icon * added docs * fixed always firing alert * fixed export of components * fixed react warning * Adding flag for notifying on state change * Updating logic in task runner * Starting to update tests * Adding tests * Fixing types check * Tests and types * Tests * Tests * Tests * Tests * Tests * Renaming field to a more descriptive name. Adding migrations * Renaming field to a more descriptive name. Adding migrations * Fixing tests * Type check and tests * Moving schedule and notify interval to bottom of flyout. Implementing dropdown from mockup in new component * Changing boolean flag to enum type and updating in triggers_actions_ui * Changing boolean flag to enum type and updating in alerts plugin * Fixing types check * Fixing monitoring jest tests * Changing last references to old variable names * Moving form inputs back to the top * Renaming to alert_notify_when * Updating functional tests * Adding new functional test for notifyWhen onActionGroupChange * Updating wording * Incorporating action subgroups into logic * PR fixes * Updating functional test * Fixing types check * Changing default throttle interval to hour * Fixing types check Co-authored-by: Gidi Meir Morris --- x-pack/plugins/alerts/common/alert.ts | 2 + .../common/alert_notify_when_type.test.ts | 18 + .../alerts/common/alert_notify_when_type.ts | 19 + x-pack/plugins/alerts/common/index.ts | 1 + .../alert_instance/alert_instance.test.ts | 108 +++++ .../server/alert_instance/alert_instance.ts | 25 + .../server/alerts_client/alerts_client.ts | 16 +- .../server/alerts_client/tests/create.test.ts | 429 ++++++++++++++++++ .../server/alerts_client/tests/find.test.ts | 3 + .../server/alerts_client/tests/get.test.ts | 2 + .../tests/get_alert_instance_summary.test.ts | 1 + .../server/alerts_client/tests/update.test.ts | 25 + .../alerts_client_conflict_retries.test.ts | 1 + ...rt_instance_summary_from_event_log.test.ts | 1 + .../lib/get_alert_notify_when_type.test.ts | 23 + .../server/lib/get_alert_notify_when_type.ts | 16 + x-pack/plugins/alerts/server/lib/index.ts | 1 + .../alerts/server/routes/create.test.ts | 3 + x-pack/plugins/alerts/server/routes/create.ts | 6 +- .../plugins/alerts/server/routes/get.test.ts | 1 + .../alerts/server/routes/update.test.ts | 4 + x-pack/plugins/alerts/server/routes/update.ts | 15 +- .../alerts/server/saved_objects/mappings.json | 3 + .../server/saved_objects/migrations.test.ts | 28 ++ .../alerts/server/saved_objects/migrations.ts | 24 +- .../task_runner/alert_task_instance.test.ts | 1 + .../server/task_runner/task_runner.test.ts | 183 ++++++++ .../alerts/server/task_runner/task_runner.ts | 60 ++- x-pack/plugins/alerts/server/types.ts | 3 + .../public/alerts/alert_form.test.tsx | 4 + .../server/alerts/base_alert.test.ts | 1 + .../monitoring/server/alerts/base_alert.ts | 1 + .../notifications/create_notifications.ts | 1 + .../notifications/update_notifications.ts | 1 + .../routes/__mocks__/request_responses.ts | 2 + .../detection_engine/rules/create_rules.ts | 1 + .../rules/patch_rules.mock.ts | 1 + .../lib/detection_engine/rules/patch_rules.ts | 1 + .../detection_engine/rules/update_rules.ts | 1 + .../schemas/rule_converters.ts | 1 + .../detection_engine/schemas/rule_schemas.ts | 2 + .../public/application/lib/alert_api.test.ts | 8 +- .../public/application/lib/alert_api.ts | 7 +- .../components/alert_details.test.tsx | 1 + .../components/alert_details_route.test.tsx | 1 + .../components/alert_instances.test.tsx | 1 + .../components/alert_instances_route.test.tsx | 1 + .../components/view_in_app.test.tsx | 1 + .../sections/alert_form/alert_add.tsx | 1 + .../sections/alert_form/alert_edit.test.tsx | 1 + .../sections/alert_form/alert_form.test.tsx | 20 - .../sections/alert_form/alert_form.tsx | 90 +--- .../alert_form/alert_notify_when.test.tsx | 142 ++++++ .../sections/alert_form/alert_notify_when.tsx | 220 +++++++++ .../sections/alert_form/alert_reducer.test.ts | 1 + .../sections/alert_form/alert_reducer.ts | 2 +- .../with_bulk_alert_api_operations.test.tsx | 1 + .../triggers_actions_ui/public/types.ts | 2 + .../common/lib/get_test_alert_data.ts | 1 + .../tests/alerting/create.ts | 1 + .../tests/alerting/find.ts | 2 + .../security_and_spaces/tests/alerting/get.ts | 1 + .../tests/alerting/update.ts | 9 + .../spaces_only/tests/alerting/create.ts | 1 + .../spaces_only/tests/alerting/find.ts | 1 + .../spaces_only/tests/alerting/get.ts | 1 + .../spaces_only/tests/alerting/index.ts | 1 + .../spaces_only/tests/alerting/migrations.ts | 9 + .../spaces_only/tests/alerting/notify_when.ts | 272 +++++++++++ .../spaces_only/tests/alerting/update.ts | 1 + .../test/functional/services/uptime/alerts.ts | 2 + .../alert_create_flyout.ts | 4 + 72 files changed, 1721 insertions(+), 124 deletions(-) create mode 100644 x-pack/plugins/alerts/common/alert_notify_when_type.test.ts create mode 100644 x-pack/plugins/alerts/common/alert_notify_when_type.ts create mode 100644 x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts create mode 100644 x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts diff --git a/x-pack/plugins/alerts/common/alert.ts b/x-pack/plugins/alerts/common/alert.ts index 88f6090d2073..e0e73e978f77 100644 --- a/x-pack/plugins/alerts/common/alert.ts +++ b/x-pack/plugins/alerts/common/alert.ts @@ -5,6 +5,7 @@ */ import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server'; +import { AlertNotifyWhenType } from './alert_notify_when_type'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AlertTypeState = Record; @@ -68,6 +69,7 @@ export interface Alert { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; executionStatus: AlertExecutionStatus; diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts new file mode 100644 index 000000000000..ad0b0430c6c1 --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.test.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. + */ + +import { validateNotifyWhenType } from './alert_notify_when_type'; + +test('validates valid notify when type', () => { + expect(validateNotifyWhenType('onActionGroupChange')).toBeUndefined(); + expect(validateNotifyWhenType('onActiveAlert')).toBeUndefined(); + expect(validateNotifyWhenType('onThrottleInterval')).toBeUndefined(); +}); +test('returns error string if input is not valid notify when type', () => { + expect(validateNotifyWhenType('randomString')).toEqual( + `string is not a valid AlertNotifyWhenType: randomString` + ); +}); diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.ts new file mode 100644 index 000000000000..4ae4be0ac20a --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const AlertNotifyWhenTypeValues = [ + 'onActionGroupChange', + 'onActiveAlert', + 'onThrottleInterval', +] as const; +export type AlertNotifyWhenType = typeof AlertNotifyWhenTypeValues[number]; + +export function validateNotifyWhenType(notifyWhen: string) { + if (AlertNotifyWhenTypeValues.includes(notifyWhen as AlertNotifyWhenType)) { + return; + } + return `string is not a valid AlertNotifyWhenType: ${notifyWhen}`; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 3e551facd98a..cbdfec642fa7 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -14,6 +14,7 @@ export * from './alert_navigation'; export * from './alert_instance_summary'; export * from './builtin_action_groups'; export * from './disabled_action_groups'; +export * from './alert_notify_when_type'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts index e680f22afad8..b428f6c1a913 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts @@ -72,6 +72,114 @@ describe('isThrottled', () => { }); }); +describe('scheduledActionGroupOrSubgroupHasChanged()', () => { + test('should be false if no last scheduled and nothing scheduled', () => { + const alertInstance = new AlertInstance(); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group and subgroup does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from undefined to defined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from defined to undefined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be true if no last scheduled and has scheduled action', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('penguin'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('penguin', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does not change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); +}); + describe('getScheduledActionOptions()', () => { test('defaults to undefined', () => { const alertInstance = new AlertInstance(); diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index ba3a2961b96f..8841f3115d54 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -70,6 +70,31 @@ export class AlertInstance< return false; } + scheduledActionGroupOrSubgroupHasChanged(): boolean { + if (!this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // it is considered a change when there are no previous scheduled actions + // and new scheduled actions + return true; + } + + if (this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // compare previous and new scheduled actions if both exist + return ( + !this.scheduledActionGroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) || + !this.scheduledActionSubgroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) + ); + } + + // no previous and no new scheduled actions + return false; + } + private scheduledActionGroupIsUnchanged( lastScheduledActions: NonNullable, scheduledExecutionOptions: ScheduledExecutionOptions diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index d697817be734..b1696696b304 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -29,8 +29,13 @@ import { AlertTaskState, AlertInstanceSummary, AlertExecutionStatusValues, + AlertNotifyWhenType, } from '../types'; -import { validateAlertTypeParams, alertExecutionStatusFromRaw } from '../lib'; +import { + validateAlertTypeParams, + alertExecutionStatusFromRaw, + getAlertNotifyWhenType, +} from '../lib'; import { GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult, InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, @@ -157,6 +162,7 @@ interface UpdateOptions { actions: NormalizedAlertAction[]; params: Record; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; }; } @@ -251,6 +257,8 @@ export class AlertsClient { const createTime = Date.now(); const { references, actions } = await this.denormalizeActions(data.actions); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); + const rawAlert: RawAlert = { ...data, ...this.apiKeyAsAlertAttributes(createdAPIKey, username), @@ -262,6 +270,7 @@ export class AlertsClient { params: validatedAlertTypeParams as RawAlert['params'], muteAll: false, mutedInstanceIds: [], + notifyWhen, executionStatus: { status: 'pending', lastExecutionDate: new Date().toISOString(), @@ -694,6 +703,7 @@ export class AlertsClient { ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); let updatedObject: SavedObject; const createAttributes = this.updateMeta({ @@ -702,6 +712,7 @@ export class AlertsClient { ...apiKeyAttributes, params: validatedAlertTypeParams as RawAlert['params'], actions, + notifyWhen, updatedBy: username, updatedAt: new Date().toISOString(), }); @@ -1326,7 +1337,7 @@ export class AlertsClient { private getPartialAlertFromRaw( id: string, - { createdAt, updatedAt, meta, scheduledTaskId, ...rawAlert }: Partial, + { createdAt, updatedAt, meta, notifyWhen, scheduledTaskId, ...rawAlert }: Partial, references: SavedObjectReference[] | undefined ): PartialAlert { // Not the prettiest code here, but if we want to use most of the @@ -1341,6 +1352,7 @@ export class AlertsClient { const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus); return { id, + notifyWhen, ...rawAlertWithoutExecutionStatus, // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index b943a21ba9bb..4e273ee3a9e4 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -68,6 +68,7 @@ function getMockData(overwrites: Record = {}): CreateOptions['d consumer: 'bar', schedule: { interval: '10s' }, throttle: null, + notifyWhen: null, params: { bar: true, }, @@ -341,6 +342,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": null, "params": Object { "bar": true, }, @@ -389,6 +391,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -488,6 +491,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -587,6 +591,7 @@ describe('create()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -626,6 +631,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -662,6 +668,7 @@ describe('create()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -740,6 +747,426 @@ describe('create()', () => { expect(alertsClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name'); }); + test('should create alert with given notifyWhen value if notifyWhen is not null', async () => { + const data = getMockData({ notifyWhen: 'onActionGroupChange', throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onThrottleInterval if notifyWhen is null and throttle is set', async () => { + const data = getMockData({ throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onThrottleInterval", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onActiveAlert if notifyWhen is null and throttle is null', async () => { + const data = getMockData(); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); alertTypeRegistry.get.mockReturnValue({ @@ -1049,6 +1476,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], @@ -1172,6 +1600,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index 232d48e25825..ff64150dc2b7 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -85,6 +85,7 @@ describe('find()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -143,6 +144,7 @@ describe('find()', () => { "alertTypeId": "myType", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -234,6 +236,7 @@ describe('find()', () => { Object { "actions": Array [], "id": "1", + "notifyWhen": undefined, "schedule": undefined, "tags": Array [ "myTag", diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 32ac57459795..e3e3630d379e 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -72,6 +72,7 @@ describe('get()', () => { }, }, ], + notifyWhen: 'onActiveAlert', }, references: [ { @@ -96,6 +97,7 @@ describe('get()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts index cb878b11548b..555c316038da 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts @@ -80,6 +80,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject = { apiKey: null, apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index 15fb1e2ec009..42cec57b555d 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -70,6 +70,7 @@ describe('update()', () => { scheduledTaskId: 'task-123', params: {}, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -144,6 +145,7 @@ describe('update()', () => { }, }, ], + notifyWhen: 'onActiveAlert', scheduledTaskId: 'task-123', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -185,6 +187,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -241,6 +244,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -295,6 +299,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -368,6 +373,7 @@ describe('update()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -418,6 +424,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -445,6 +452,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -479,6 +487,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -540,6 +549,7 @@ describe('update()', () => { params: { bar: true, }, + notifyWhen: 'onThrottleInterval', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), actions: [ @@ -583,6 +593,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -611,6 +622,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -645,6 +657,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -702,6 +715,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -830,6 +844,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -937,6 +952,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -998,6 +1014,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1118,6 +1135,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1149,6 +1167,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1185,6 +1204,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1220,6 +1240,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1273,6 +1294,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }); @@ -1296,6 +1318,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }) @@ -1339,6 +1362,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }); @@ -1368,6 +1392,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }) ).rejects.toThrow(); diff --git a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts index 60e733b49b04..aaa70a2594a5 100644 --- a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts @@ -105,6 +105,7 @@ async function update(success: boolean) { tags: ['bar'], params: { bar: true }, throttle: '10s', + notifyWhen: null, actions: [], }, }); diff --git a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts index a53a162cc508..d6357494546b 100644 --- a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts @@ -648,6 +648,7 @@ const BaseAlert: SanitizedAlert = { tags: [], consumer: 'alert-consumer', throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], params: { bar: true }, diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts new file mode 100644 index 000000000000..51eb1277a61c --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { getAlertNotifyWhenType } from './get_alert_notify_when_type'; + +test(`should return 'notifyWhen' value if value is set and throttle is null`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', null)).toEqual('onActionGroupChange'); +}); + +test(`should return 'notifyWhen' value if value is set and throttle is defined`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', '10m')).toEqual('onActionGroupChange'); +}); + +test(`should return 'onThrottleInterval' value if 'notifyWhen' is null and throttle is defined`, () => { + expect(getAlertNotifyWhenType(null, '10m')).toEqual('onThrottleInterval'); +}); + +test(`should return 'onActiveAlert' value if 'notifyWhen' is null and throttle is null`, () => { + expect(getAlertNotifyWhenType(null, null)).toEqual('onActiveAlert'); +}); diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts new file mode 100644 index 000000000000..c871ba0c6e60 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts @@ -0,0 +1,16 @@ +/* + * 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 { AlertNotifyWhenType } from '../types'; + +export function getAlertNotifyWhenType( + notifyWhen: AlertNotifyWhenType | null, + throttle: string | null +): AlertNotifyWhenType { + // We allow notifyWhen to be null for backwards compatibility. If it is null, determine its + // value based on whether the throttle is set to a value or null + return notifyWhen ? notifyWhen! : throttle ? 'onThrottleInterval' : 'onActiveAlert'; +} diff --git a/x-pack/plugins/alerts/server/lib/index.ts b/x-pack/plugins/alerts/server/lib/index.ts index 32047ae5cbfa..d4662c02c031 100644 --- a/x-pack/plugins/alerts/server/lib/index.ts +++ b/x-pack/plugins/alerts/server/lib/index.ts @@ -7,6 +7,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_duration'; export { LicenseState } from './license_state'; export { validateAlertTypeParams } from './validate_alert_type_params'; +export { getAlertNotifyWhenType } from './get_alert_notify_when_type'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; export { executionStatusFromState, diff --git a/x-pack/plugins/alerts/server/routes/create.test.ts b/x-pack/plugins/alerts/server/routes/create.test.ts index 51c5d2525631..90c075f129b8 100644 --- a/x-pack/plugins/alerts/server/routes/create.test.ts +++ b/x-pack/plugins/alerts/server/routes/create.test.ts @@ -36,6 +36,7 @@ describe('createAlertRoute', () => { bar: true, }, throttle: '30s', + notifyWhen: 'onActionGroupChange', actions: [ { group: 'default', @@ -56,6 +57,7 @@ describe('createAlertRoute', () => { apiKey: '', apiKeyOwner: '', mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', createdAt, updatedAt, id: '123', @@ -110,6 +112,7 @@ describe('createAlertRoute', () => { "alertTypeId": "1", "consumer": "bar", "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/routes/create.ts b/x-pack/plugins/alerts/server/routes/create.ts index 91a81f6d84b7..f54aec8fe0cf 100644 --- a/x-pack/plugins/alerts/server/routes/create.ts +++ b/x-pack/plugins/alerts/server/routes/create.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { Alert, BASE_ALERT_API_PATH } from '../types'; +import { Alert, AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../types'; export const bodySchema = schema.object({ name: schema.string(), @@ -38,6 +38,7 @@ export const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -61,7 +62,8 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const alert = req.body; - const alertRes: Alert = await alertsClient.create({ data: alert }); + const notifyWhen = alert?.notifyWhen ? (alert.notifyWhen as AlertNotifyWhenType) : null; + const alertRes: Alert = await alertsClient.create({ data: { ...alert, notifyWhen } }); return res.ok({ body: alertRes, }); diff --git a/x-pack/plugins/alerts/server/routes/get.test.ts b/x-pack/plugins/alerts/server/routes/get.test.ts index c60177e90b79..51ac64bbef18 100644 --- a/x-pack/plugins/alerts/server/routes/get.test.ts +++ b/x-pack/plugins/alerts/server/routes/get.test.ts @@ -46,6 +46,7 @@ describe('getAlertRoute', () => { tags: ['foo'], enabled: true, muteAll: false, + notifyWhen: 'onActionGroupChange', createdBy: '', updatedBy: '', apiKey: '', diff --git a/x-pack/plugins/alerts/server/routes/update.test.ts b/x-pack/plugins/alerts/server/routes/update.test.ts index dedb08a9972c..89619bd85370 100644 --- a/x-pack/plugins/alerts/server/routes/update.test.ts +++ b/x-pack/plugins/alerts/server/routes/update.test.ts @@ -10,6 +10,7 @@ import { mockLicenseState } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertNotifyWhenType } from '../../common'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -41,6 +42,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, }; it('updates an alert with proper parameters', async () => { @@ -78,6 +80,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange', }, }, ['ok'] @@ -100,6 +103,7 @@ describe('updateAlertRoute', () => { }, ], "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "otherField": false, }, diff --git a/x-pack/plugins/alerts/server/routes/update.ts b/x-pack/plugins/alerts/server/routes/update.ts index 9b2fe9a43810..96b3156525f7 100644 --- a/x-pack/plugins/alerts/server/routes/update.ts +++ b/x-pack/plugins/alerts/server/routes/update.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../../common'; const paramSchema = schema.object({ id: schema.string(), @@ -39,6 +39,7 @@ const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -62,11 +63,19 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - const { name, actions, params, schedule, tags, throttle } = req.body; + const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; return res.ok({ body: await alertsClient.update({ id, - data: { name, actions, params, schedule, tags, throttle }, + data: { + name, + actions, + params, + schedule, + tags, + throttle, + notifyWhen: notifyWhen as AlertNotifyWhenType, + }, }), }); }) diff --git a/x-pack/plugins/alerts/server/saved_objects/mappings.json b/x-pack/plugins/alerts/server/saved_objects/mappings.json index f40a7d9075ee..f0c5c28ecaea 100644 --- a/x-pack/plugins/alerts/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerts/server/saved_objects/mappings.json @@ -74,6 +74,9 @@ "throttle": { "type": "keyword" }, + "notifyWhen": { + "type": "keyword" + }, "muteAll": { "type": "boolean" }, diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index a4cbc18e13b4..abbce7a009b9 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -277,6 +277,7 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.updated_at, + notifyWhen: 'onActiveAlert', }, }); }); @@ -289,6 +290,33 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is null', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({}); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is set', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({ throttle: '5m' }); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onThrottleInterval', }, }); }); diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.ts index d8ebced03c5a..1b9c5dac23b8 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.ts @@ -37,15 +37,18 @@ export function getMigrations( ) ); - const migrationAlertUpdatedAtDate = encryptedSavedObjects.createMigration( - // migrate all documents in 7.11 in order to add the "updatedAt" field + const migrationAlertUpdatedAtAndNotifyWhen = encryptedSavedObjects.createMigration< + RawAlert, + RawAlert + >( + // migrate all documents in 7.11 in order to add the "updatedAt" and "notifyWhen" fields (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(setAlertUpdatedAtDate) + pipeMigrations(setAlertUpdatedAtDate, setNotifyWhen) ); return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtDate, '7.11.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), }; } @@ -79,6 +82,19 @@ const setAlertUpdatedAtDate = ( }; }; +const setNotifyWhen = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + const notifyWhen = doc.attributes.throttle ? 'onThrottleInterval' : 'onActiveAlert'; + return { + ...doc, + attributes: { + ...doc.attributes, + notifyWhen, + }, + }; +}; + const consumersToChange: Map = new Map( Object.entries({ alerting: 'alerts', diff --git a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts index cf0dd9d135e2..09236ec5e0ad 100644 --- a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts @@ -27,6 +27,7 @@ const alert: SanitizedAlert = { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index d4ea74c008b4..d3d0a54417ee 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -92,6 +92,7 @@ describe('Task Runner', () => { updatedAt: new Date('2019-02-12T21:01:22.479Z'), throttle: null, muteAll: false, + notifyWhen: 'onActiveAlert', enabled: true, alertTypeId: alertType.id, apiKey: '', @@ -533,6 +534,188 @@ describe('Task Runner', () => { ); }); + test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert instance state does not change', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { date: '1970-01-01T00:00:00.000Z', group: 'default' }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + }, + ], + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "event": Object { + "action": "execute", + "outcome": "success", + }, + "kibana": Object { + "alerting": Object { + "status": "active", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "alert executed: test:1: 'alert-name'", + }, + ], + ] + `); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { lastScheduledActions: { group: 'newGroup', date: new Date().toISOString() } }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices + .alertInstanceFactory('1') + .scheduleActionsWithSubGroup('default', 'subgroup1'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + group: 'default', + subgroup: 'newSubgroup', + date: new Date().toISOString(), + }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + test('includes the apiKey in the request used to initialize the actionsClient', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 6bc6271dd6d5..2073528f2c75 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -171,7 +171,16 @@ export class TaskRunner { spaceId: string, event: Event ): Promise { - const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; + const { + throttle, + notifyWhen, + muteAll, + mutedInstanceIds, + name, + tags, + createdBy, + updatedBy, + } = alert; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, @@ -257,24 +266,39 @@ export class TaskRunner { alertLabel, }); + const instancesToExecute = + notifyWhen === 'onActionGroupChange' + ? Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const shouldExecuteAction = alertInstance.scheduledActionGroupOrSubgroupHasChanged(); + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is active but action group has not changed` + ); + } + return shouldExecuteAction; + } + ) + : Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const throttled = alertInstance.isThrottled(throttle); + const muted = mutedInstanceIdsSet.has(alertInstanceName); + const shouldExecuteAction = !throttled && !muted; + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ + muted ? 'muted' : 'throttled' + }` + ); + } + return shouldExecuteAction; + } + ); + await Promise.all( - Object.entries(instancesWithScheduledActions) - .filter(([alertInstanceName, alertInstance]: [string, AlertInstance]) => { - const throttled = alertInstance.isThrottled(throttle); - const muted = mutedInstanceIdsSet.has(alertInstanceName); - const shouldExecuteAction = !throttled && !muted; - if (!shouldExecuteAction) { - this.logger.debug( - `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ - muted ? 'muted' : 'throttled' - }` - ); - } - return shouldExecuteAction; - }) - .map(([id, alertInstance]: [string, AlertInstance]) => - this.executeAlertInstance(id, alertInstance, executionHandler) - ) + instancesToExecute.map(([id, alertInstance]: [string, AlertInstance]) => + this.executeAlertInstance(id, alertInstance, executionHandler) + ) ); } else { this.logger.debug(`no scheduling of actions for alert ${alertLabel}: alert is muted.`); diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 889812350675..a5aee8dbf3b6 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -28,6 +28,7 @@ import { AlertExecutionStatuses, AlertExecutionStatusErrorReasons, AlertsHealth, + AlertNotifyWhenType, } from '../common'; export type WithoutQueryAndParams = Pick>; @@ -152,6 +153,7 @@ export interface RawAlert extends SavedObjectAttributes { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; meta?: AlertMeta; @@ -162,6 +164,7 @@ export type AlertInfoParams = Pick< RawAlert, | 'params' | 'throttle' + | 'notifyWhen' | 'muteAll' | 'mutedInstanceIds' | 'name' diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index e4ee805c4b48..369f03ab8cb1 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -156,6 +156,10 @@ describe('alert_form', () => { }); it('should update throttle value', async () => { + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onThrottleInterval"]').simulate('click'); + wrapper.update(); const newThrottle = 17; const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); expect(throttleField.exists()).toBeTruthy(); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts index d23d6c8b32f1..8cba1537965f 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts @@ -64,6 +64,7 @@ describe('BaseAlert', () => { }, tags: [], throttle: '1d', + notifyWhen: null, }, }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 6e89caf43230..1d8de2bab015 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -177,6 +177,7 @@ export class BaseAlert { name, alertTypeId, throttle, + notifyWhen: null, schedule: { interval }, actions: alertActions, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts index 8f6826cec536..5731a51aeabc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts @@ -31,5 +31,6 @@ export const createNotifications = async ({ enabled, actions: actions.map(transformRuleToAlertAction), throttle: null, + notifyWhen: null, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts index 17024c7c0d75..d6c897321511 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts @@ -35,6 +35,7 @@ export const updateNotifications = async ({ ruleAlertId, }, throttle: null, + notifyWhen: null, }, }); } else if (interval && !notification) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index ea95c4fa7884..3f8dcefd01e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -404,6 +404,7 @@ export const getResult = (): RuleAlertType => ({ enabled: true, actions: [], throttle: null, + notifyWhen: null, createdBy: 'elastic', updatedBy: 'elastic', apiKey: null, @@ -629,6 +630,7 @@ export const getNotificationResult = (): RuleNotificationAlertType => ({ }, ], throttle: null, + notifyWhen: null, apiKey: null, apiKeyOwner: 'elastic', createdBy: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 3c814ce7e660..0519a98df1fa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -114,6 +114,7 @@ export const createRules = async ({ enabled, actions: actions.map(transformRuleToAlertAction), throttle: null, + notifyWhen: null, }, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index f01ea3c85550..b2303d48b051 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -105,6 +105,7 @@ const rule: SanitizedAlert = { enabled: true, actions: [], throttle: null, + notifyWhen: null, createdBy: 'elastic', updatedBy: 'elastic', apiKeyOwner: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 8e10fc21f040..c86526cee930 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -172,6 +172,7 @@ export const patchRules = async ({ const newRule = { tags: addTags(tags ?? rule.tags, rule.params.ruleId, rule.params.immutable), throttle: null, + notifyWhen: null, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index dab8769bcaa6..c63bd01cd181 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -74,6 +74,7 @@ export const updateRules = async ({ schedule: { interval: ruleUpdate.interval ?? '5m' }, actions: throttle === 'rule' ? (ruleUpdate.actions ?? []).map(transformRuleToAlertAction) : [], throttle: null, + notifyWhen: null, }; const update = await alertsClient.update({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index 86d85cd2a066..7585f1a1b510 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -143,6 +143,7 @@ export const convertCreateAPIToInternalSchema = ( enabled: input.enabled ?? true, actions: input.throttle === 'rule' ? (input.actions ?? []).map(transformRuleToAlertAction) : [], throttle: null, + notifyWhen: null, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 5bb8d6d6746f..0af9d6ac4377 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -182,6 +182,7 @@ export const internalRuleCreate = t.type({ actions: actionsCamel, params: ruleParams, throttle: throttleOrNull, + notifyWhen: t.null, }); export type InternalRuleCreate = t.TypeOf; @@ -194,6 +195,7 @@ export const internalRuleUpdate = t.type({ actions: actionsCamel, params: ruleParams, throttle: throttleOrNull, + notifyWhen: t.null, }); export type InternalRuleUpdate = t.TypeOf; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index e1011e2fe69b..32b663c5693f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -29,7 +29,7 @@ import { mapFiltersToKql, } from './alert_api'; import uuid from 'uuid'; -import { ALERTS_FEATURE_ID } from '../../../../alerts/common'; +import { AlertNotifyWhenType, ALERTS_FEATURE_ID } from '../../../../alerts/common'; const http = httpServiceMock.createStartContract(); @@ -548,6 +548,7 @@ describe('createAlert', () => { actions: [], params: {}, throttle: null, + notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, createdAt: new Date('1970-01-01T00:00:00.000Z'), updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, @@ -573,7 +574,7 @@ describe('createAlert', () => { Array [ "/api/alerts/alert", Object { - "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerts\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", + "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerts\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"notifyWhen\\":\\"onActionGroupChange\\",\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", }, ] `); @@ -596,6 +597,7 @@ describe('updateAlert', () => { updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, apiKeyOwner: null, + notifyWhen: 'onThrottleInterval' as AlertNotifyWhenType, }; const resolvedValue: Alert = { ...alertToUpdate, @@ -619,7 +621,7 @@ describe('updateAlert', () => { Array [ "/api/alerts/alert/123", Object { - "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[]}", + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"notifyWhen\\":\\"onThrottleInterval\\"}", }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index d34481850ca4..52ab33566da7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -195,12 +195,15 @@ export async function updateAlert({ id, }: { http: HttpSetup; - alert: Pick; + alert: Pick< + AlertUpdates, + 'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'notifyWhen' + >; id: string; }): Promise { return await http.put(`${BASE_ALERT_API_PATH}/alert/${id}`, { body: JSON.stringify( - pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions']) + pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions', 'notifyWhen']) ), }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index b19b6eb5f7a3..c10653d14d40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -775,6 +775,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 43ece9fc10c3..48360647e24e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -397,6 +397,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { 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 f7b00a2ccf0b..be68036c0f74 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 @@ -286,6 +286,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 24e20c5d477f..b24d552bd5c4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -126,6 +126,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx index d026c43b8496..f025c886e071 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx @@ -84,6 +84,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 03c8b539227a..5ab2c7f5a586 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -59,6 +59,7 @@ const AlertAdd = ({ }, actions: [], tags: [], + notifyWhen: 'onActionGroupChange', ...(initialValues ? initialValues : {}), }), [alertTypeId, consumer, initialValues] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 4af54bbb80e9..25f830df58df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -103,6 +103,7 @@ describe('alert_edit', () => { tags: [], name: 'test alert', throttle: null, + notifyWhen: null, apiKeyOwner: null, createdBy: 'elastic', updatedBy: 'elastic', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 785eaeb9059d..26aca1bb5e4a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -378,26 +378,6 @@ describe('alert_form', () => { expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - it('should update throttle value', async () => { - const newThrottle = 17; - await setup(); - const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleField.exists()).toBeTruthy(); - throttleField.at(1).simulate('change', { target: { value: newThrottle.toString() } }); - const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); - }); - - it('should unset throttle value', async () => { - const newThrottle = ''; - await setup(); - const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleField.exists()).toBeTruthy(); - throttleField.at(1).simulate('change', { target: { value: newThrottle } }); - const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); - }); - it('renders alert type description', async () => { await setup(); const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 3a8835825acd..3259e405e3f7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -32,8 +32,6 @@ import { EuiNotificationBadge, EuiErrorBoundary, } from '@elastic/eui'; -import { some, filter, map, fold } from 'fp-ts/lib/Option'; -import { pipe } from 'fp-ts/lib/pipeable'; import { capitalize, isObject } from 'lodash'; import { KibanaFeature } from '../../../../../features/public'; import { @@ -67,6 +65,7 @@ import './alert_form.scss'; import { useKibana } from '../../../common/lib/kibana'; import { recoveredActionGroupMessage } from '../../constants'; import { getDefaultsForActionParams } from '../../lib/get_defaults_for_action_params'; +import { AlertNotifyWhen } from './alert_notify_when'; const ENTER_KEY = 13; @@ -168,7 +167,7 @@ export const AlertForm = ({ alert.throttle ? getDurationNumberInItsUnit(alert.throttle) : null ); const [alertThrottleUnit, setAlertThrottleUnit] = useState( - alert.throttle ? getDurationUnitValue(alert.throttle) : 'm' + alert.throttle ? getDurationUnitValue(alert.throttle) : 'h' ); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState(null); @@ -572,22 +571,6 @@ export const AlertForm = ({ ); - const labelForAlertRenotify = ( - <> - {' '} - - - ); - return ( @@ -608,7 +591,6 @@ export const AlertForm = ({ fullWidth autoFocus={true} isInvalid={errors.name.length > 0 && alert.name !== undefined} - compressed name="name" data-test-subj="alertNameInput" value={alert.name || ''} @@ -633,7 +615,6 @@ export const AlertForm = ({ { @@ -674,7 +655,6 @@ export const AlertForm = ({ fullWidth min={1} isInvalid={errors.interval.length > 0} - compressed value={alertInterval || ''} name="interval" data-test-subj="intervalInput" @@ -689,7 +669,6 @@ export const AlertForm = ({ { @@ -702,52 +681,25 @@ export const AlertForm = ({ - - - - { - pipe( - some(e.target.value.trim()), - filter((value) => value !== ''), - map((value) => parseInt(value, 10)), - filter((value) => !isNaN(value)), - fold( - () => { - // unset throttle - setAlertThrottle(null); - setAlertProperty('throttle', null); - }, - (throttle) => { - setAlertThrottle(throttle); - setAlertProperty('throttle', `${throttle}${alertThrottleUnit}`); - } - ) - ); - }} - /> - - - { - setAlertThrottleUnit(e.target.value); - if (alertThrottle) { - setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); - } - }} - /> - - - + { + setAlertProperty('notifyWhen', notifyWhen); + }, + [setAlertProperty] + )} + onThrottleChange={useCallback( + (throttle: number | null, throttleUnit: string) => { + setAlertThrottle(throttle); + setAlertThrottleUnit(throttleUnit); + setAlertProperty('throttle', throttle ? `${throttle}${throttleUnit}` : null); + }, + [setAlertProperty] + )} + /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx new file mode 100644 index 000000000000..62e35229c902 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Alert } from '../../../types'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { AlertNotifyWhen } from './alert_notify_when'; + +describe('alert_notify_when', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const onNotifyWhenChange = jest.fn(); + const onThrottleChange = jest.fn(); + + describe('action_frequency_form new alert', () => { + let wrapper: ReactWrapper; + + async function setup(overrides = {}) { + const initialAlert = ({ + name: 'test', + params: {}, + consumer: ALERTS_FEATURE_ID, + schedule: { + interval: '1m', + }, + actions: [], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', + ...overrides, + } as unknown) as Alert; + + wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it(`should determine initial selection from throttle value if 'notifyWhen' is null`, async () => { + await setup({ notifyWhen: null }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActiveAlert'); + }); + + it(`should correctly select 'onActionGroupChange' option on initial render`, async () => { + await setup(); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActionGroupChange'); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + }); + + it(`should correctly select 'onActiveAlert' option on initial render`, async () => { + await setup({ + notifyWhen: 'onActiveAlert', + }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActiveAlert'); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + }); + + it(`should correctly select 'onThrottleInterval' option on initial render and render throttle inputs`, async () => { + await setup({ + notifyWhen: 'onThrottleInterval', + }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onThrottleInterval'); + + const throttleInput = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleInput.exists()).toBeTruthy(); + expect(throttleInput.at(1).prop('value')).toEqual(1); + + const throttleUnitInput = wrapper.find('[data-test-subj="throttleUnitInput"]'); + expect(throttleUnitInput.exists()).toBeTruthy(); + expect(throttleUnitInput.at(1).prop('value')).toEqual('m'); + }); + + it('should update action frequency type correctly', async () => { + await setup(); + + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onActiveAlert"]').simulate('click'); + wrapper.update(); + expect(onNotifyWhenChange).toHaveBeenCalledWith('onActiveAlert'); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'm'); + + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onActionGroupChange"]').simulate('click'); + wrapper.update(); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + expect(onNotifyWhenChange).toHaveBeenCalledWith('onActionGroupChange'); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'm'); + }); + + it('should renders throttle input when custom throttle is selected and update throttle value', async () => { + await setup({ + notifyWhen: 'onThrottleInterval', + }); + + const newThrottle = 17; + const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleField.exists()).toBeTruthy(); + throttleField.at(1).simulate('change', { target: { value: newThrottle.toString() } }); + const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); + expect(onThrottleChange).toHaveBeenCalledWith(17, 'm'); + + const newThrottleUnit = 'h'; + const throttleUnitField = wrapper.find('[data-test-subj="throttleUnitInput"]'); + expect(throttleUnitField.exists()).toBeTruthy(); + throttleUnitField.at(1).simulate('change', { target: { value: newThrottleUnit } }); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'h'); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx new file mode 100644 index 000000000000..da872484dda4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx @@ -0,0 +1,220 @@ +/* + * 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, useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiFormRow, + EuiFieldNumber, + EuiSelect, + EuiText, + EuiSpacer, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; +import { some, filter, map } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { InitialAlert } from './alert_reducer'; +import { getTimeOptions } from '../../../common/lib/get_time_options'; +import { AlertNotifyWhenType } from '../../../types'; + +const DEFAULT_NOTIFY_WHEN_VALUE: AlertNotifyWhenType = 'onActionGroupChange'; + +const NOTIFY_WHEN_OPTIONS: Array> = [ + { + value: 'onActionGroupChange', + inputDisplay: 'Run only on status change', + 'data-test-subj': 'onActionGroupChange', + dropdownDisplay: ( + + + + + +

+ +

+
+
+ ), + }, + { + value: 'onActiveAlert', + inputDisplay: 'Run every time alert is active', + 'data-test-subj': 'onActiveAlert', + dropdownDisplay: ( + + + + + +

+ +

+
+
+ ), + }, + { + value: 'onThrottleInterval', + inputDisplay: 'Set a custom action interval', + 'data-test-subj': 'onThrottleInterval', + dropdownDisplay: ( + + + + + +

+ +

+
+
+ ), + }, +]; + +interface AlertNotifyWhenProps { + alert: InitialAlert; + throttle: number | null; + throttleUnit: string; + onNotifyWhenChange: (notifyWhen: AlertNotifyWhenType) => void; + onThrottleChange: (throttle: number | null, throttleUnit: string) => void; +} + +export const AlertNotifyWhen = ({ + alert, + throttle, + throttleUnit, + onNotifyWhenChange, + onThrottleChange, +}: AlertNotifyWhenProps) => { + const [alertThrottle, setAlertThrottle] = useState(throttle || 1); + const [showCustomThrottleOpts, setShowCustomThrottleOpts] = useState(false); + const [notifyWhenValue, setNotifyWhenValue] = useState( + DEFAULT_NOTIFY_WHEN_VALUE + ); + + useEffect(() => { + if (alert.notifyWhen) { + setNotifyWhenValue(alert.notifyWhen); + } else { + // If 'notifyWhen' is not set, derive value from existence of throttle value + setNotifyWhenValue(alert.throttle ? 'onThrottleInterval' : 'onActiveAlert'); + } + }, [alert]); + + useEffect(() => { + setShowCustomThrottleOpts(notifyWhenValue === 'onThrottleInterval'); + }, [notifyWhenValue]); + + const onNotifyWhenValueChange = useCallback((newValue: AlertNotifyWhenType) => { + onThrottleChange(newValue === 'onThrottleInterval' ? alertThrottle : null, throttleUnit); + onNotifyWhenChange(newValue); + setNotifyWhenValue(newValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const labelForAlertRenotify = ( + <> + {' '} + + + ); + + return ( + + + + + + {showCustomThrottleOpts && ( + + + + + + { + pipe( + some(e.target.value.trim()), + filter((value) => value !== ''), + map((value) => parseInt(value, 10)), + filter((value) => !isNaN(value)), + map((value) => { + setAlertThrottle(value); + onThrottleChange(value, throttleUnit); + }) + ); + }} + /> + + + { + onThrottleChange(throttle, e.target.value); + }} + /> + + + + + )} + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts index 4e4d8e237aa2..71f486cb311b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts @@ -18,6 +18,7 @@ describe('alert reducer', () => { }, actions: [], tags: [], + notifyWhen: 'onActionGroupChange', } as unknown) as Alert; }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts index e54895318fc7..b86e0d155531 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts @@ -10,7 +10,7 @@ import { AlertActionParam, IntervalSchedule } from '../../../../../alerts/common import { Alert, AlertAction } from '../../../types'; export type InitialAlert = Partial & - Pick; + Pick; interface CommandType< T extends diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 47ef744f5d95..4de4ea02e567 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -250,6 +250,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index acd242eed17f..f950bbbd8ed2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -20,6 +20,7 @@ import { AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, + AlertNotifyWhenType, } from '../../alerts/common'; export { Alert, @@ -30,6 +31,7 @@ export { AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, + AlertNotifyWhenType, }; export { ActionType }; diff --git a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts index 2e7a4e325094..e4db829cc283 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts @@ -13,6 +13,7 @@ export function getTestAlertData(overwrites = {}) { consumer: 'alertsFixture', schedule: { interval: '1m' }, throttle: '1m', + notifyWhen: 'onThrottleInterval', actions: [], params: {}, ...overwrites, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts index 19d90378e8b7..720a0f20648f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts @@ -115,6 +115,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: user.username, apiKeyOwner: user.username, muteAll: false, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts index adfe5cd27b33..55b148f0c501 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts @@ -75,6 +75,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { createdAt: match.createdAt, updatedAt: match.updatedAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: 'elastic', apiKeyOwner: 'elastic', muteAll: false, @@ -272,6 +273,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { apiKeyOwner: null, muteAll: false, mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', createdAt: match.createdAt, updatedAt: match.updatedAt, executionStatus: match.executionStatus, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts index 93e9be771ab5..87d7b2327dd6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts @@ -71,6 +71,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { updatedAt: response.body.updatedAt, createdAt: response.body.createdAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: 'elastic', apiKeyOwner: 'elastic', muteAll: false, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index 6b03492432ac..b3ad00bd1ce8 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -73,6 +73,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }, ], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -171,6 +172,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -254,6 +256,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -348,6 +351,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -451,6 +455,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -529,6 +534,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -798,6 +804,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1m' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -863,6 +870,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1m' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -938,6 +946,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index cf7fc9edd952..8bf0a2a0f034 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -83,6 +83,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { updatedBy: null, apiKeyOwner: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: response.body.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 850ec24789f5..ffe25cfe684a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -52,6 +52,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { scheduledTaskId: match.scheduledTaskId, updatedBy: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: match.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts index 14a57f57c923..8323e2658532 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts @@ -46,6 +46,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { updatedBy: null, apiKeyOwner: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: response.body.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index d8a0f279222c..2b24a75fab84 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_space1')); loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./builtin_alert_types')); + loadTestFile(require.resolve('./notify_when')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index bd6afacf206d..56866b36a292 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -91,5 +91,14 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(response.status).to.eql(200); expect(response.body.updatedAt).to.eql('2020-06-17T15:35:39.839Z'); }); + + it('7.11.0 migrates alerts to contain `notifyWhen` field', async () => { + const response = await supertest.get( + `${getUrlPrefix(``)}/api/alerts/alert/74f3e6d7-b7bb-477d-ac28-92ee22728e6e` + ); + + expect(response.status).to.eql(200); + expect(response.body.notifyWhen).to.eql('onActiveAlert'); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts new file mode 100644 index 000000000000..234fbb580210 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts @@ -0,0 +1,272 @@ +/* + * 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 expect from '@kbn/expect'; + +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover, getTestAlertData, getEventLog } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { IValidatedEvent } from '../../../../../plugins/event_log/server'; + +// eslint-disable-next-line import/no-default-export +export default function createNotifyWhenTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('notifyWhen', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => await objectRemover.removeAll()); + + it(`alert with notifyWhen=onActiveAlert should always execute actions `, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [true, true, true, false, true, true], + }; + const expectedActionGroupBasedOnPattern = pattern.instance.map((active: boolean) => + active ? 'default' : 'recovered' + ); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 6 }], // one more action (for recovery) will be executed after the last pattern fires + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + + it(`alert with notifyWhen=onActionGroupChange should execute actions when action group changes`, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [true, true, false, false, true, false], + }; + const expectedActionGroupBasedOnPattern = ['default', 'recovered', 'default', 'recovered']; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActionGroupChange', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 4 }], + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + + it(`alert with notifyWhen=onActionGroupChange should only execute actions when action subgroup changes`, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [ + 'subgroup1', + 'subgroup1', + false, + false, + 'subgroup1', + 'subgroup2', + 'subgroup2', + false, + ], + }; + const expectedActionGroupBasedOnPattern = [ + 'default', + 'recovered', + 'default', + 'default', + 'recovered', + ]; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActionGroupChange', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 5 }], + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + }); +} + +function getEventsByAction(events: IValidatedEvent[], action: string) { + return events.filter((event) => event?.event?.action === action); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index f44a7d713187..f7e6a402e406 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -54,6 +54,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { apiKeyOwner: null, muteAll: false, mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', scheduledTaskId: createdAlert.scheduledTaskId, createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, diff --git a/x-pack/test/functional/services/uptime/alerts.ts b/x-pack/test/functional/services/uptime/alerts.ts index fa0c035b9183..67b80e2ddf32 100644 --- a/x-pack/test/functional/services/uptime/alerts.ts +++ b/x-pack/test/functional/services/uptime/alerts.ts @@ -38,6 +38,8 @@ export function UptimeAlertsProvider({ getService }: FtrProviderContext) { return testSubjects.setValue('intervalInput', value); }, async setAlertThrottleInterval(value: string) { + await testSubjects.click('notifyWhenSelect'); + await testSubjects.click('onThrottleInterval'); return testSubjects.setValue('throttleInput', value); }, async setAlertExpressionValue( diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7444c17a7a45..2598fc890211 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -72,6 +72,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const alertName = generateUniqueKey(); await defineAlert(alertName); + await testSubjects.click('notifyWhenSelect'); + await testSubjects.click('onThrottleInterval'); + await testSubjects.setValue('throttleInput', '10'); + await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('addNewActionConnectorButton-.slack'); const slackConnectorName = generateUniqueKey();