[Actions] Notify only on action group change (#82969)

* plugged Task Manager lifecycle into status reactively

* fixed tests

* Revert "fixed tests"

This reverts commit e9f2cd05bd.

* 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 <github@gidi.io>
This commit is contained in:
ymao1 2020-12-10 15:51:52 -05:00 committed by GitHub
parent 317608420a
commit ab082647ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 1721 additions and 124 deletions

View file

@ -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<string, any>;
@ -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;

View file

@ -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`
);
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
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}`;
}

View file

@ -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;

View file

@ -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();

View file

@ -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<AlertInstanceMeta['lastScheduledActions']>,
scheduledExecutionOptions: ScheduledExecutionOptions<State, Context>

View file

@ -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<string, unknown>;
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<RawAlert>;
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<RawAlert>,
{ createdAt, updatedAt, meta, notifyWhen, scheduledTaskId, ...rawAlert }: Partial<RawAlert>,
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

View file

@ -68,6 +68,7 @@ function getMockData(overwrites: Record<string, unknown> = {}): 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'],

View file

@ -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",

View file

@ -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,
},

View file

@ -80,6 +80,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject<RawAlert> = {
apiKey: null,
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -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();

View file

@ -105,6 +105,7 @@ async function update(success: boolean) {
tags: ['bar'],
params: { bar: true },
throttle: '10s',
notifyWhen: null,
actions: [],
},
});

View file

@ -648,6 +648,7 @@ const BaseAlert: SanitizedAlert = {
tags: [],
consumer: 'alert-consumer',
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
params: { bar: true },

View file

@ -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');
});

View file

@ -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';
}

View file

@ -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,

View file

@ -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,
},

View file

@ -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,
});

View file

@ -46,6 +46,7 @@ describe('getAlertRoute', () => {
tags: ['foo'],
enabled: true,
muteAll: false,
notifyWhen: 'onActionGroupChange',
createdBy: '',
updatedBy: '',
apiKey: '',

View file

@ -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,
},

View file

@ -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,
},
}),
});
})

View file

@ -74,6 +74,9 @@
"throttle": {
"type": "keyword"
},
"notifyWhen": {
"type": "keyword"
},
"muteAll": {
"type": "boolean"
},

View file

@ -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',
},
});
});

View file

@ -37,15 +37,18 @@ export function getMigrations(
)
);
const migrationAlertUpdatedAtDate = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
// 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<RawAlert> => 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<RawAlert>
): SavedObjectUnsanitizedDoc<RawAlert> => {
const notifyWhen = doc.attributes.throttle ? 'onThrottleInterval' : 'onActiveAlert';
return {
...doc,
attributes: {
...doc.attributes,
notifyWhen,
},
};
};
const consumersToChange: Map<string, string> = new Map(
Object.entries({
alerting: 'alerts',

View file

@ -27,6 +27,7 @@ const alert: SanitizedAlert = {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -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);

View file

@ -171,7 +171,16 @@ export class TaskRunner {
spaceId: string,
event: Event
): Promise<AlertTaskState> {
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.`);

View file

@ -28,6 +28,7 @@ import {
AlertExecutionStatuses,
AlertExecutionStatusErrorReasons,
AlertsHealth,
AlertNotifyWhenType,
} from '../common';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
@ -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'

View file

@ -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();

View file

@ -64,6 +64,7 @@ describe('BaseAlert', () => {
},
tags: [],
throttle: '1d',
notifyWhen: null,
},
});
});

View file

@ -177,6 +177,7 @@ export class BaseAlert {
name,
alertTypeId,
throttle,
notifyWhen: null,
schedule: { interval },
actions: alertActions,
},

View file

@ -31,5 +31,6 @@ export const createNotifications = async ({
enabled,
actions: actions.map(transformRuleToAlertAction),
throttle: null,
notifyWhen: null,
},
});

View file

@ -35,6 +35,7 @@ export const updateNotifications = async ({
ruleAlertId,
},
throttle: null,
notifyWhen: null,
},
});
} else if (interval && !notification) {

View file

@ -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',

View file

@ -114,6 +114,7 @@ export const createRules = async ({
enabled,
actions: actions.map(transformRuleToAlertAction),
throttle: null,
notifyWhen: null,
},
});
};

View file

@ -105,6 +105,7 @@ const rule: SanitizedAlert = {
enabled: true,
actions: [],
throttle: null,
notifyWhen: null,
createdBy: 'elastic',
updatedBy: 'elastic',
apiKeyOwner: 'elastic',

View file

@ -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),

View file

@ -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({

View file

@ -143,6 +143,7 @@ export const convertCreateAPIToInternalSchema = (
enabled: input.enabled ?? true,
actions: input.throttle === 'rule' ? (input.actions ?? []).map(transformRuleToAlertAction) : [],
throttle: null,
notifyWhen: null,
};
};

View file

@ -182,6 +182,7 @@ export const internalRuleCreate = t.type({
actions: actionsCamel,
params: ruleParams,
throttle: throttleOrNull,
notifyWhen: t.null,
});
export type InternalRuleCreate = t.TypeOf<typeof internalRuleCreate>;
@ -194,6 +195,7 @@ export const internalRuleUpdate = t.type({
actions: actionsCamel,
params: ruleParams,
throttle: throttleOrNull,
notifyWhen: t.null,
});
export type InternalRuleUpdate = t.TypeOf<typeof internalRuleUpdate>;

View file

@ -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\\"}",
},
]
`);

View file

@ -195,12 +195,15 @@ export async function updateAlert({
id,
}: {
http: HttpSetup;
alert: Pick<AlertUpdates, 'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions'>;
alert: Pick<
AlertUpdates,
'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'notifyWhen'
>;
id: string;
}): Promise<Alert> {
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'])
),
});
}

View file

@ -775,6 +775,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -397,6 +397,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -286,6 +286,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -126,6 +126,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -84,6 +84,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -59,6 +59,7 @@ const AlertAdd = ({
},
actions: [],
tags: [],
notifyWhen: 'onActionGroupChange',
...(initialValues ? initialValues : {}),
}),
[alertTypeId, consumer, initialValues]

View file

@ -103,6 +103,7 @@ describe('alert_edit', () => {
tags: [],
name: 'test alert',
throttle: null,
notifyWhen: null,
apiKeyOwner: null,
createdBy: 'elastic',
updatedBy: 'elastic',

View file

@ -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"]');

View file

@ -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<string>(
alert.throttle ? getDurationUnitValue(alert.throttle) : 'm'
alert.throttle ? getDurationUnitValue(alert.throttle) : 'h'
);
const [defaultActionGroupId, setDefaultActionGroupId] = useState<string | undefined>(undefined);
const [alertTypesIndex, setAlertTypesIndex] = useState<AlertTypeIndex | null>(null);
@ -572,22 +571,6 @@ export const AlertForm = ({
</>
);
const labelForAlertRenotify = (
<>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertForm.renotifyFieldLabel"
defaultMessage="Notify every"
/>{' '}
<EuiIconTip
position="right"
type="questionInCircle"
content={i18n.translate('xpack.triggersActionsUI.sections.alertForm.renotifyWithTooltip', {
defaultMessage: 'Define how often to repeat the action while the alert is active.',
})}
/>
</>
);
return (
<EuiForm>
<EuiFlexGrid columns={2}>
@ -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 = ({
<EuiComboBox
noSuggestions
fullWidth
compressed
data-test-subj="tagsComboBox"
selectedOptions={tagsOptions}
onCreateOption={(searchValue: string) => {
@ -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 = ({
<EuiFlexItem grow={false}>
<EuiSelect
fullWidth
compressed
value={alertIntervalUnit}
options={getTimeOptions(alertInterval ?? 1)}
onChange={(e) => {
@ -702,52 +681,25 @@ export const AlertForm = ({
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={labelForAlertRenotify}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldNumber
fullWidth
min={1}
compressed
value={alertThrottle || ''}
name="throttle"
data-test-subj="throttleInput"
onChange={(e) => {
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}`);
}
)
);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
compressed
value={alertThrottleUnit}
options={getTimeOptions(alertThrottle ?? 1)}
onChange={(e) => {
setAlertThrottleUnit(e.target.value);
if (alertThrottle) {
setAlertProperty('throttle', `${alertThrottle}${e.target.value}`);
}
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<AlertNotifyWhen
alert={alert}
throttle={alertThrottle}
throttleUnit={alertThrottleUnit}
onNotifyWhenChange={useCallback(
(notifyWhen) => {
setAlertProperty('notifyWhen', notifyWhen);
},
[setAlertProperty]
)}
onThrottleChange={useCallback(
(throttle: number | null, throttleUnit: string) => {
setAlertThrottle(throttle);
setAlertThrottleUnit(throttleUnit);
setAlertProperty('throttle', throttle ? `${throttle}${throttleUnit}` : null);
},
[setAlertProperty]
)}
/>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="m" />

View file

@ -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<any>;
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(
<AlertNotifyWhen
alert={initialAlert}
throttle={null}
throttleUnit="m"
onNotifyWhenChange={onNotifyWhenChange}
onThrottleChange={onThrottleChange}
/>
);
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');
});
});
});

View file

@ -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<EuiSuperSelectOption<AlertNotifyWhenType>> = [
{
value: 'onActionGroupChange',
inputDisplay: 'Run only on status change',
'data-test-subj': 'onActionGroupChange',
dropdownDisplay: (
<Fragment>
<strong>
<FormattedMessage
defaultMessage="Run only on status change"
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActionGroupChange.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run when the alert status changes."
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActionGroupChange.description"
/>
</p>
</EuiText>
</Fragment>
),
},
{
value: 'onActiveAlert',
inputDisplay: 'Run every time alert is active',
'data-test-subj': 'onActiveAlert',
dropdownDisplay: (
<Fragment>
<strong>
<FormattedMessage
defaultMessage="Run every time alert is active"
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActiveAlert.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run with every active alert interval."
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActiveAlert.description"
/>
</p>
</EuiText>
</Fragment>
),
},
{
value: 'onThrottleInterval',
inputDisplay: 'Set a custom action interval',
'data-test-subj': 'onThrottleInterval',
dropdownDisplay: (
<Fragment>
<strong>
<FormattedMessage
defaultMessage="Set a custom action interval"
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onThrottleInterval.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run using the interval you set."
id="xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onThrottleInterval.description"
/>
</p>
</EuiText>
</Fragment>
),
},
];
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<number>(throttle || 1);
const [showCustomThrottleOpts, setShowCustomThrottleOpts] = useState<boolean>(false);
const [notifyWhenValue, setNotifyWhenValue] = useState<AlertNotifyWhenType>(
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 = (
<>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertForm.renotifyFieldLabel"
defaultMessage="Notify every"
/>{' '}
<EuiIconTip
position="right"
type="questionInCircle"
content={i18n.translate('xpack.triggersActionsUI.sections.alertForm.renotifyWithTooltip', {
defaultMessage: 'Define how often to repeat the action while the alert is active.',
})}
/>
</>
);
return (
<Fragment>
<EuiFormRow fullWidth label={labelForAlertRenotify}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiSuperSelect
data-test-subj="notifyWhenSelect"
options={NOTIFY_WHEN_OPTIONS}
valueOfSelected={notifyWhenValue}
onChange={onNotifyWhenValueChange}
/>
{showCustomThrottleOpts && (
<Fragment>
<EuiSpacer />
<EuiFormRow fullWidth>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldNumber
fullWidth
min={1}
value={alertThrottle}
name="throttle"
data-test-subj="throttleInput"
prepend={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.label',
{
defaultMessage: 'Every',
}
)}
onChange={(e) => {
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);
})
);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
data-test-subj="throttleUnitInput"
value={throttleUnit}
options={getTimeOptions(throttle ?? 1)}
onChange={(e) => {
onThrottleChange(throttle, e.target.value);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</Fragment>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</Fragment>
);
};

View file

@ -18,6 +18,7 @@ describe('alert reducer', () => {
},
actions: [],
tags: [],
notifyWhen: 'onActionGroupChange',
} as unknown) as Alert;
});

View file

@ -10,7 +10,7 @@ import { AlertActionParam, IntervalSchedule } from '../../../../../alerts/common
import { Alert, AlertAction } from '../../../types';
export type InitialAlert = Partial<Alert> &
Pick<Alert, 'params' | 'consumer' | 'schedule' | 'actions' | 'tags'>;
Pick<Alert, 'params' | 'consumer' | 'schedule' | 'actions' | 'tags' | 'notifyWhen'>;
interface CommandType<
T extends

View file

@ -250,6 +250,7 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {

View file

@ -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 };

View file

@ -13,6 +13,7 @@ export function getTestAlertData(overwrites = {}) {
consumer: 'alertsFixture',
schedule: { interval: '1m' },
throttle: '1m',
notifyWhen: 'onThrottleInterval',
actions: [],
params: {},
...overwrites,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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}`)

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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'));

View file

@ -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');
});
});
}

View file

@ -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);
}

View file

@ -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,

View file

@ -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(

View file

@ -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();