diff --git a/x-pack/plugins/alerts/common/alert_status.ts b/x-pack/plugins/alerts/common/alert_status.ts new file mode 100644 index 0000000000000..517db6d6cb243 --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_status.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +type AlertStatusValues = 'OK' | 'Active' | 'Error'; +type AlertInstanceStatusValues = 'OK' | 'Active'; + +export interface AlertStatus { + id: string; + name: string; + tags: string[]; + alertTypeId: string; + consumer: string; + muteAll: boolean; + throttle: string | null; + enabled: boolean; + statusStartDate: string; + statusEndDate: string; + status: AlertStatusValues; + lastRun?: string; + errorMessages: Array<{ date: string; message: string }>; + instances: Record; +} + +export interface AlertInstanceStatus { + status: AlertInstanceStatusValues; + muted: boolean; + activeStartDate?: string; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index b839c07a9db89..0922e164a3aa3 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -9,6 +9,7 @@ export * from './alert_type'; export * from './alert_instance'; export * from './alert_task_instance'; export * from './alert_navigation'; +export * from './alert_status'; export interface ActionGroup { id: string; diff --git a/x-pack/plugins/alerts/server/alerts_client.mock.ts b/x-pack/plugins/alerts/server/alerts_client.mock.ts index be70e441b6fc5..b61139ae72c99 100644 --- a/x-pack/plugins/alerts/server/alerts_client.mock.ts +++ b/x-pack/plugins/alerts/server/alerts_client.mock.ts @@ -25,6 +25,7 @@ const createAlertsClientMock = () => { muteInstance: jest.fn(), unmuteInstance: jest.fn(), listAlertTypes: jest.fn(), + getAlertStatus: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index c25e040ad09ce..d994269366ae6 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -11,16 +11,22 @@ import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { alertsAuthorizationMock } from './authorization/alerts_authorization.mock'; import { TaskStatus } from '../../task_manager/server'; -import { IntervalSchedule } from './types'; +import { IntervalSchedule, RawAlert } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { actionsClientMock, actionsAuthorizationMock } from '../../actions/server/mocks'; import { AlertsAuthorization } from './authorization/alerts_authorization'; import { ActionsAuthorization } from '../../actions/server'; +import { eventLogClientMock } from '../../event_log/server/mocks'; +import { QueryEventsBySavedObjectResult } from '../../event_log/server'; +import { SavedObject } from 'kibana/server'; +import { EventsFactory } from './lib/alert_status_from_event_log.test'; const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const eventLogClient = eventLogClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); @@ -39,6 +45,7 @@ const alertsClientParams: jest.Mocked = { logger: loggingSystemMock.create().get(), encryptedSavedObjectsClient: encryptedSavedObjects, getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), }; beforeEach(() => { @@ -91,17 +98,33 @@ beforeEach(() => { async executor() {}, producer: 'alerts', })); + alertsClientParams.getEventLogClient.mockResolvedValue(eventLogClient); }); -const mockedDate = new Date('2019-02-12T21:01:22.479Z'); -// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockedDateString = '2019-02-12T21:01:22.479Z'; +const mockedDate = new Date(mockedDateString); +const DateOriginal = Date; + +// A version of date that responds to `new Date(null|undefined)` and `Date.now()` +// by returning a fixed date, otherwise should be same as Date. +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ (global as any).Date = class Date { - constructor() { - return mockedDate; + constructor(...args: unknown[]) { + // sometimes the ctor has no args, sometimes has a single `null` arg + if (args[0] == null) { + // @ts-ignore + return mockedDate; + } else { + // @ts-ignore + return new DateOriginal(...args); + } } static now() { return mockedDate.getTime(); } + static parse(string: string) { + return DateOriginal.parse(string); + } }; function getMockData(overwrites: Record = {}): CreateOptions['data'] { @@ -2295,6 +2318,219 @@ describe('getAlertState()', () => { }); }); +const AlertStatusFindEventsResult: QueryEventsBySavedObjectResult = { + page: 1, + per_page: 10000, + total: 0, + data: [], +}; + +const AlertStatusIntervalSeconds = 1; + +const BaseAlertStatusSavedObject: SavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'alert-consumer', + schedule: { interval: `${AlertStatusIntervalSeconds}s` }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: mockedDateString, + apiKey: null, + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + }, + references: [], +}; + +function getAlertStatusSavedObject(attributes: Partial = {}): SavedObject { + return { + ...BaseAlertStatusSavedObject, + attributes: { ...BaseAlertStatusSavedObject.attributes, ...attributes }, + }; +} + +describe('getAlertStatus()', () => { + let alertsClient: AlertsClient; + + beforeEach(() => { + alertsClient = new AlertsClient(alertsClientParams); + }); + + test('runs as expected with some event log data', async () => { + const alertSO = getAlertStatusSavedObject({ mutedInstanceIds: ['instance-muted-no-activity'] }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(alertSO); + + const eventsFactory = new EventsFactory(mockedDateString); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-currently-active') + .addNewInstance('instance-previously-active') + .addActiveInstance('instance-currently-active') + .addActiveInstance('instance-previously-active') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-previously-active') + .addActiveInstance('instance-currently-active') + .getEvents(); + const eventsResult = { + ...AlertStatusFindEventsResult, + total: events.length, + data: events, + }; + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(eventsResult); + + const dateStart = new Date(Date.now() - 60 * 1000).toISOString(); + + const result = await alertsClient.getAlertStatus({ id: '1', dateStart }); + expect(result).toMatchInlineSnapshot(` + Object { + "alertTypeId": "123", + "consumer": "alert-consumer", + "enabled": true, + "errorMessages": Array [], + "id": "1", + "instances": Object { + "instance-currently-active": Object { + "activeStartDate": "2019-02-12T21:01:22.479Z", + "muted": false, + "status": "Active", + }, + "instance-muted-no-activity": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + "instance-previously-active": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2019-02-12T21:01:32.479Z", + "muteAll": false, + "name": "alert-name", + "status": "Active", + "statusEndDate": "2019-02-12T21:01:22.479Z", + "statusStartDate": "2019-02-12T21:00:22.479Z", + "tags": Array [ + "tag-1", + "tag-2", + ], + "throttle": null, + } + `); + }); + + // Further tests don't check the result of `getAlertStatus()`, as the result + // is just the result from the `alertStatusFromEventLog()`, which itself + // has a complete set of tests. These tests just make sure the data gets + // sent into `getAlertStatus()` as appropriate. + + test('calls saved objects and event log client with default params', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + await alertsClient.getAlertStatus({ id: '1' }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + Object { + "end": "2019-02-12T21:01:22.479Z", + "page": 1, + "per_page": 10000, + "sort_order": "desc", + "start": "2019-02-12T21:00:22.479Z", + }, + ] + `); + // calculate the expected start/end date for one test + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + expect(end).toBe(mockedDateString); + + const startMillis = Date.parse(start!); + const endMillis = Date.parse(end!); + const expectedDuration = 60 * AlertStatusIntervalSeconds * 1000; + expect(endMillis - startMillis).toBeGreaterThan(expectedDuration - 2); + expect(endMillis - startMillis).toBeLessThan(expectedDuration + 2); + }); + + test('calls event log client with start date', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = new Date(Date.now() - 60 * AlertStatusIntervalSeconds * 1000).toISOString(); + await alertsClient.getAlertStatus({ id: '1', dateStart }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + + expect({ start, end }).toMatchInlineSnapshot(` + Object { + "end": "2019-02-12T21:01:22.479Z", + "start": "2019-02-12T21:00:22.479Z", + } + `); + }); + + test('calls event log client with relative start date', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = '2m'; + await alertsClient.getAlertStatus({ id: '1', dateStart }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + + expect({ start, end }).toMatchInlineSnapshot(` + Object { + "end": "2019-02-12T21:01:22.479Z", + "start": "2019-02-12T20:59:22.479Z", + } + `); + }); + + test('invalid start date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = 'ain"t no way this will get parsed as a date'; + expect(alertsClient.getAlertStatus({ id: '1', dateStart })).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateStart: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('saved object get throws an error', async () => { + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('OMG!')); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + expect(alertsClient.getAlertStatus({ id: '1' })).rejects.toMatchInlineSnapshot(`[Error: OMG!]`); + }); + + test('findEvents throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockRejectedValueOnce(new Error('OMG 2!')); + + // error eaten but logged + await alertsClient.getAlertStatus({ id: '1' }); + }); +}); + describe('find()', () => { const listedTypes = new Set([ { diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index dd66ccc7a0256..5ed93d9124039 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -24,6 +24,7 @@ import { IntervalSchedule, SanitizedAlert, AlertTaskState, + AlertStatus, } from './types'; import { validateAlertTypeParams } from './lib'; import { @@ -41,6 +42,11 @@ import { WriteOperations, ReadOperations, } from './authorization/alerts_authorization'; +import { IEventLogClient } from '../../../plugins/event_log/server'; +import { parseIsoOrRelativeDate } from './lib/iso_or_relative_date'; +import { alertStatusFromEventLog } from './lib/alert_status_from_event_log'; +import { IEvent } from '../../event_log/server'; +import { parseDuration } from '../common/parse_duration'; export interface RegistryAlertTypeWithAuth extends RegistryAlertType { authorizedConsumers: string[]; @@ -67,6 +73,7 @@ export interface ConstructorOptions { createAPIKey: (name: string) => Promise; invalidateAPIKey: (params: InvalidateAPIKeyParams) => Promise; getActionsClient: () => Promise; + getEventLogClient: () => Promise; } export interface MuteOptions extends IndexType { @@ -132,6 +139,11 @@ interface UpdateOptions { }; } +interface GetAlertStatusParams { + id: string; + dateStart?: string; +} + export class AlertsClient { private readonly logger: Logger; private readonly getUserName: () => Promise; @@ -147,6 +159,7 @@ export class AlertsClient { ) => Promise; private readonly getActionsClient: () => Promise; private readonly actionsAuthorization: ActionsAuthorization; + private readonly getEventLogClient: () => Promise; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; constructor({ @@ -163,6 +176,7 @@ export class AlertsClient { encryptedSavedObjectsClient, getActionsClient, actionsAuthorization, + getEventLogClient, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -177,6 +191,7 @@ export class AlertsClient { this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; this.getActionsClient = getActionsClient; this.actionsAuthorization = actionsAuthorization; + this.getEventLogClient = getEventLogClient; } public async create({ data, options }: CreateOptions): Promise { @@ -269,6 +284,72 @@ export class AlertsClient { } } + public async getAlertStatus({ id, dateStart }: GetAlertStatusParams): Promise { + const eventLogClient = await this.getEventLogClient(); + + this.logger.debug('getAlertStatus(): getting the alert'); + const alert = await this.get({ id }); + await this.authorization.ensureAuthorized( + alert.alertTypeId, + alert.consumer, + ReadOperations.GetAlertStatus + ); + + // default duration of status is 60 * alert interval + const dateNow = new Date(); + const durationMillis = parseDuration(alert.schedule.interval) * 60; + const defaultDateStart = new Date(dateNow.valueOf() - durationMillis); + const parsedDateStart = parseDate(dateStart, 'dateStart', defaultDateStart); + + this.logger.debug('getAlertStatus(): search the event log'); + let events: IEvent[]; + try { + const queryResults = await eventLogClient.findEventsBySavedObject('alert', id, { + page: 1, + per_page: 10000, + start: parsedDateStart.toISOString(), + end: dateNow.toISOString(), + sort_order: 'desc', + }); + events = queryResults.data; + } catch (err) { + this.logger.debug(`alertsClient.getAlertStatus(): error searching event log: ${err.message}`); + events = []; + } + + return alertStatusFromEventLog({ + alert, + events, + dateStart: parsedDateStart.toISOString(), + dateEnd: dateNow.toISOString(), + }); + + function parseDate( + dateString: string | undefined, + propertyName: string, + defaultValue: Date + ): Date { + if (dateString === undefined) { + return defaultValue; + } + + const parsedDate = parseIsoOrRelativeDate(dateString); + if (parsedDate === undefined) { + throw Boom.badRequest( + i18n.translate('xpack.alerts.alertsClient.getAlertStatus.invalidDate', { + defaultMessage: 'Invalid date for parameter {field}: "{dateValue}"', + values: { + field: propertyName, + dateValue: dateString, + }, + }) + ); + } + + return parsedDate; + } + } + public async find({ options: { fields, ...options } = {}, }: { options?: FindOptions } = {}): Promise { @@ -283,7 +364,6 @@ export class AlertsClient { ? `${options.filter} and ${authorizationFilter}` : authorizationFilter; } - const { page, per_page: perPage, diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index 16b5af499bb90..a5eb371633f1e 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -22,6 +22,7 @@ import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mock import { featuresPluginMock } from '../../features/server/mocks'; import { AuditLogger } from '../../security/server'; import { ALERTS_FEATURE_ID } from '../common'; +import { eventLogMock } from '../../event_log/server/mocks'; jest.mock('./alerts_client'); jest.mock('./authorization/alerts_authorization'); @@ -42,6 +43,7 @@ const alertsClientFactoryParams: jest.Mocked = { encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), actions: actionsMock.createStart(), features, + eventLog: eventLogMock.createStart(), }; const fakeRequest = ({ headers: {}, @@ -119,6 +121,7 @@ test('creates an alerts client with proper constructor arguments when security i namespace: 'default', getUserName: expect.any(Function), getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), createAPIKey: expect.any(Function), invalidateAPIKey: expect.any(Function), encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient, @@ -164,6 +167,7 @@ test('creates an alerts client with proper constructor arguments', async () => { invalidateAPIKey: expect.any(Function), encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient, getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 79b0ccaf1f0bc..83202424c9773 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -16,6 +16,7 @@ import { PluginStartContract as FeaturesPluginStart } from '../../features/serve import { AlertsAuthorization } from './authorization/alerts_authorization'; import { AlertsAuthorizationAuditLogger } from './authorization/audit_logger'; import { Space } from '../../spaces/server'; +import { IEventLogClientService } from '../../../plugins/event_log/server'; export interface AlertsClientFactoryOpts { logger: Logger; @@ -28,6 +29,7 @@ export interface AlertsClientFactoryOpts { encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actions: ActionsPluginStartContract; features: FeaturesPluginStart; + eventLog: IEventLogClientService; } export class AlertsClientFactory { @@ -42,6 +44,7 @@ export class AlertsClientFactory { private encryptedSavedObjectsClient!: EncryptedSavedObjectsClient; private actions!: ActionsPluginStartContract; private features!: FeaturesPluginStart; + private eventLog!: IEventLogClientService; public initialize(options: AlertsClientFactoryOpts) { if (this.isInitialized) { @@ -58,10 +61,11 @@ export class AlertsClientFactory { this.encryptedSavedObjectsClient = options.encryptedSavedObjectsClient; this.actions = options.actions; this.features = options.features; + this.eventLog = options.eventLog; } public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): AlertsClient { - const { securityPluginSetup, actions, features } = this; + const { securityPluginSetup, actions, eventLog, features } = this; const spaceId = this.getSpaceId(request); const authorization = new AlertsAuthorization({ authorization: securityPluginSetup?.authz, @@ -135,6 +139,9 @@ export class AlertsClientFactory { async getActionsClient() { return actions.getActionsClientWithRequest(request); }, + async getEventLogClient() { + return eventLog.getClient(request); + }, }); } } diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts index 33a9a0bf0396e..b2a214eae9316 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts @@ -18,6 +18,7 @@ import { Space } from '../../../spaces/server'; export enum ReadOperations { Get = 'get', GetAlertState = 'getAlertState', + GetAlertStatus = 'getAlertStatus', Find = 'find', } diff --git a/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts new file mode 100644 index 0000000000000..15570d3032f24 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts @@ -0,0 +1,464 @@ +/* + * 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 { SanitizedAlert, AlertStatus } from '../types'; +import { IValidatedEvent } from '../../../event_log/server'; +import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from '../plugin'; +import { alertStatusFromEventLog } from './alert_status_from_event_log'; + +const ONE_HOUR_IN_MILLIS = 60 * 60 * 1000; +const dateStart = '2020-06-18T00:00:00.000Z'; +const dateEnd = dateString(dateStart, ONE_HOUR_IN_MILLIS); + +describe('alertStatusFromEventLog', () => { + test('no events and muted ids', async () => { + const alert = createAlert({}); + const events: IValidatedEvent[] = []; + const status: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + expect(status).toMatchInlineSnapshot(` + Object { + "alertTypeId": "123", + "consumer": "alert-consumer", + "enabled": false, + "errorMessages": Array [], + "id": "alert-123", + "instances": Object {}, + "lastRun": undefined, + "muteAll": false, + "name": "alert-name", + "status": "OK", + "statusEndDate": "2020-06-18T01:00:00.000Z", + "statusStartDate": "2020-06-18T00:00:00.000Z", + "tags": Array [], + "throttle": null, + } + `); + }); + + test('different alert properties', async () => { + const alert = createAlert({ + id: 'alert-456', + alertTypeId: '456', + schedule: { interval: '100s' }, + enabled: true, + name: 'alert-name-2', + tags: ['tag-1', 'tag-2'], + consumer: 'alert-consumer-2', + throttle: '1h', + muteAll: true, + }); + const events: IValidatedEvent[] = []; + const status: AlertStatus = alertStatusFromEventLog({ + alert, + events, + dateStart: dateString(dateEnd, ONE_HOUR_IN_MILLIS), + dateEnd: dateString(dateEnd, ONE_HOUR_IN_MILLIS * 2), + }); + + expect(status).toMatchInlineSnapshot(` + Object { + "alertTypeId": "456", + "consumer": "alert-consumer-2", + "enabled": true, + "errorMessages": Array [], + "id": "alert-456", + "instances": Object {}, + "lastRun": undefined, + "muteAll": true, + "name": "alert-name-2", + "status": "OK", + "statusEndDate": "2020-06-18T03:00:00.000Z", + "statusStartDate": "2020-06-18T02:00:00.000Z", + "tags": Array [ + "tag-1", + "tag-2", + ], + "throttle": "1h", + } + `); + }); + + test('two muted instances', async () => { + const alert = createAlert({ + mutedInstanceIds: ['instance-1', 'instance-2'], + }); + const events: IValidatedEvent[] = []; + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + }, + "lastRun": undefined, + "status": "OK", + } + `); + }); + + test('active alert but no instances', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory.addExecute().advanceTime(10000).addExecute().getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object {}, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('active alert with no instances but has errors', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute('oof!') + .advanceTime(10000) + .addExecute('rut roh!') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, errorMessages, instances } = alertStatus; + expect({ lastRun, status, errorMessages, instances }).toMatchInlineSnapshot(` + Object { + "errorMessages": Array [ + Object { + "date": "2020-06-18T00:00:00.000Z", + "message": "oof!", + }, + Object { + "date": "2020-06-18T00:00:10.000Z", + "message": "rut roh!", + }, + ], + "instances": Object {}, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Error", + } + `); + }); + + test('alert with currently inactive instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('alert with currently inactive instance, no new-instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('alert with currently active instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with currently active instance, no new-instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with active and inactive muted alerts', async () => { + const alert = createAlert({ mutedInstanceIds: ['instance-1', 'instance-2'] }); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .addNewInstance('instance-2') + .addActiveInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .addResolvedInstance('instance-2') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": true, + "status": "Active", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with active and inactive alerts over many executes', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .addNewInstance('instance-2') + .addActiveInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .addResolvedInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": false, + "status": "Active", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:30.000Z", + "status": "Active", + } + `); + }); +}); + +function dateString(isoBaseDate: string, offsetMillis = 0): string { + return new Date(Date.parse(isoBaseDate) + offsetMillis).toISOString(); +} + +export class EventsFactory { + private events: IValidatedEvent[] = []; + + constructor(private date: string = dateStart) {} + + getEvents(): IValidatedEvent[] { + // ES normally returns events sorted newest to oldest, so we need to sort + // that way also + const events = this.events.slice(); + events.sort((a, b) => -a!['@timestamp']!.localeCompare(b!['@timestamp']!)); + return events; + } + + getTime(): string { + return this.date; + } + + advanceTime(millis: number): EventsFactory { + this.date = dateString(this.date, millis); + return this; + } + + addExecute(errorMessage?: string): EventsFactory { + let event: IValidatedEvent = { + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.execute, + }, + }; + + if (errorMessage) { + event = { ...event, error: { message: errorMessage } }; + } + + this.events.push(event); + return this; + } + + addActiveInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.activeInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } + + addNewInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.newInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } + + addResolvedInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.resolvedInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } +} + +function createAlert(overrides: Partial): SanitizedAlert { + return { ...BaseAlert, ...overrides }; +} + +const BaseAlert: SanitizedAlert = { + id: 'alert-123', + alertTypeId: '123', + schedule: { interval: '10s' }, + enabled: false, + name: 'alert-name', + tags: [], + consumer: 'alert-consumer', + throttle: null, + muteAll: false, + mutedInstanceIds: [], + params: { bar: true }, + actions: [], + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, +}; diff --git a/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts new file mode 100644 index 0000000000000..606bd44c6990c --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts @@ -0,0 +1,123 @@ +/* + * 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 { SanitizedAlert, AlertStatus, AlertInstanceStatus } from '../types'; +import { IEvent } from '../../../event_log/server'; +import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from '../plugin'; + +export interface AlertStatusFromEventLogParams { + alert: SanitizedAlert; + events: IEvent[]; + dateStart: string; + dateEnd: string; +} + +export function alertStatusFromEventLog(params: AlertStatusFromEventLogParams): AlertStatus { + // initialize the result + const { alert, events, dateStart, dateEnd } = params; + const alertStatus: AlertStatus = { + id: alert.id, + name: alert.name, + tags: alert.tags, + alertTypeId: alert.alertTypeId, + consumer: alert.consumer, + statusStartDate: dateStart, + statusEndDate: dateEnd, + status: 'OK', + muteAll: alert.muteAll, + throttle: alert.throttle, + enabled: alert.enabled, + lastRun: undefined, + errorMessages: [], + instances: {}, + }; + + const instances = new Map(); + + // loop through the events + // should be sorted newest to oldest, we want oldest to newest, so reverse + for (const event of events.reverse()) { + const timeStamp = event?.['@timestamp']; + if (timeStamp === undefined) continue; + + const provider = event?.event?.provider; + if (provider !== EVENT_LOG_PROVIDER) continue; + + const action = event?.event?.action; + if (action === undefined) continue; + + if (action === EVENT_LOG_ACTIONS.execute) { + alertStatus.lastRun = timeStamp; + + const errorMessage = event?.error?.message; + if (errorMessage !== undefined) { + alertStatus.status = 'Error'; + alertStatus.errorMessages.push({ + date: timeStamp, + message: errorMessage, + }); + } else { + alertStatus.status = 'OK'; + } + + continue; + } + + const instanceId = event?.kibana?.alerting?.instance_id; + if (instanceId === undefined) continue; + + const status = getAlertInstanceStatus(instances, instanceId); + switch (action) { + case EVENT_LOG_ACTIONS.newInstance: + status.activeStartDate = timeStamp; + // intentionally no break here + case EVENT_LOG_ACTIONS.activeInstance: + status.status = 'Active'; + break; + case EVENT_LOG_ACTIONS.resolvedInstance: + status.status = 'OK'; + status.activeStartDate = undefined; + } + } + + // set the muted status of instances + for (const instanceId of alert.mutedInstanceIds) { + getAlertInstanceStatus(instances, instanceId).muted = true; + } + + // convert the instances map to object form + const instanceIds = Array.from(instances.keys()).sort(); + for (const instanceId of instanceIds) { + alertStatus.instances[instanceId] = instances.get(instanceId)!; + } + + // set the overall alert status to Active if appropriate + if (alertStatus.status !== 'Error') { + if (Array.from(instances.values()).some((instance) => instance.status === 'Active')) { + alertStatus.status = 'Active'; + } + } + + alertStatus.errorMessages.sort((a, b) => a.date.localeCompare(b.date)); + + return alertStatus; +} + +// return an instance status object, creating and adding to the map if needed +function getAlertInstanceStatus( + instances: Map, + instanceId: string +): AlertInstanceStatus { + if (instances.has(instanceId)) return instances.get(instanceId)!; + + const status: AlertInstanceStatus = { + status: 'OK', + muted: false, + activeStartDate: undefined, + }; + instances.set(instanceId, status); + return status; +} diff --git a/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts new file mode 100644 index 0000000000000..91272c1cca3b5 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { parseIsoOrRelativeDate } from './iso_or_relative_date'; + +describe('parseIsoOrRelativeDate', () => { + test('handles ISO dates', () => { + const date = new Date(); + const parsedDate = parseIsoOrRelativeDate(date.toISOString()); + expect(parsedDate?.valueOf()).toBe(date.valueOf()); + }); + + test('handles relative dates', () => { + const hoursDiff = 1; + const date = new Date(Date.now() - hoursDiff * 60 * 60 * 1000); + const parsedDate = parseIsoOrRelativeDate(`${hoursDiff}h`); + const diff = Math.abs(parsedDate!.valueOf() - date.valueOf()); + expect(diff).toBeLessThan(1000); + }); + + test('returns undefined for invalid date strings', () => { + const parsedDate = parseIsoOrRelativeDate('this shall not pass'); + expect(parsedDate).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts new file mode 100644 index 0000000000000..77c4eefa04439 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts @@ -0,0 +1,27 @@ +/* + * 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 { parseDuration } from '../../common/parse_duration'; + +/** + * Parse an ISO date or NNx duration string as a Date + * + * @param dateString an ISO date or NNx "duration" string representing now-duration + * @returns a Date or undefined if the dateString was not valid + */ +export function parseIsoOrRelativeDate(dateString: string): Date | undefined { + const epochMillis = Date.parse(dateString); + if (!isNaN(epochMillis)) return new Date(epochMillis); + + let millis: number; + try { + millis = parseDuration(dateString); + } catch (err) { + return; + } + + return new Date(Date.now() - millis); +} diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 2f0df44197553..985a59392ecf2 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -38,6 +38,7 @@ import { findAlertRoute, getAlertRoute, getAlertStateRoute, + getAlertStatusRoute, listAlertTypesRoute, updateAlertRoute, enableAlertRoute, @@ -57,16 +58,17 @@ import { import { Services } from './types'; import { registerAlertsUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; -import { IEventLogger, IEventLogService } from '../../event_log/server'; +import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; import { setupSavedObjects } from './saved_objects'; -const EVENT_LOG_PROVIDER = 'alerting'; +export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { execute: 'execute', executeAction: 'execute-action', newInstance: 'new-instance', resolvedInstance: 'resolved-instance', + activeInstance: 'active-instance', }; export interface PluginSetupContract { @@ -92,6 +94,7 @@ export interface AlertingPluginsStart { taskManager: TaskManagerStartContract; encryptedSavedObjects: EncryptedSavedObjectsPluginStart; features: FeaturesPluginStart; + eventLog: IEventLogClientService; } export class AlertingPlugin { @@ -187,6 +190,7 @@ export class AlertingPlugin { findAlertRoute(router, this.licenseState); getAlertRoute(router, this.licenseState); getAlertStateRoute(router, this.licenseState); + getAlertStatusRoute(router, this.licenseState); listAlertTypesRoute(router, this.licenseState); updateAlertRoute(router, this.licenseState); enableAlertRoute(router, this.licenseState); @@ -233,6 +237,7 @@ export class AlertingPlugin { }, actions: plugins.actions, features: plugins.features, + eventLog: plugins.eventLog, }); const getAlertsClientWithRequest = (request: KibanaRequest) => { diff --git a/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts b/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts new file mode 100644 index 0000000000000..1b4cb1941018b --- /dev/null +++ b/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { getAlertStatusRoute } from './get_alert_status'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { mockLicenseState } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { alertsClientMock } from '../alerts_client.mock'; +import { AlertStatus } from '../types'; + +const alertsClient = alertsClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('getAlertStatusRoute', () => { + const dateString = new Date().toISOString(); + const mockedAlertStatus: AlertStatus = { + id: '', + name: '', + tags: [], + alertTypeId: '', + consumer: '', + muteAll: false, + throttle: null, + enabled: false, + statusStartDate: dateString, + statusEndDate: dateString, + status: 'OK', + errorMessages: [], + instances: {}, + }; + + it('gets alert status', async () => { + const licenseState = mockLicenseState(); + const router = httpServiceMock.createRouter(); + + getAlertStatusRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/status"`); + + alertsClient.getAlertStatus.mockResolvedValueOnce(mockedAlertStatus); + + const [context, req, res] = mockHandlerArguments( + { alertsClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(alertsClient.getAlertStatus).toHaveBeenCalledTimes(1); + expect(alertsClient.getAlertStatus.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "dateStart": undefined, + "id": "1", + }, + ] + `); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('returns NOT-FOUND when alert is not found', async () => { + const licenseState = mockLicenseState(); + const router = httpServiceMock.createRouter(); + + getAlertStatusRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + alertsClient.getAlertStatus = jest + .fn() + .mockResolvedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1')); + + const [context, req, res] = mockHandlerArguments( + { alertsClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['notFound'] + ); + + expect(await handler(context, req, res)).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/alerts/server/routes/get_alert_status.ts b/x-pack/plugins/alerts/server/routes/get_alert_status.ts new file mode 100644 index 0000000000000..eab18c50189f4 --- /dev/null +++ b/x-pack/plugins/alerts/server/routes/get_alert_status.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { LicenseState } from '../lib/license_state'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { BASE_ALERT_API_PATH } from '../../common'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const querySchema = schema.object({ + dateStart: schema.maybe(schema.string()), +}); + +export const getAlertStatusRoute = (router: IRouter, licenseState: LicenseState) => { + router.get( + { + path: `${BASE_ALERT_API_PATH}/alert/{id}/status`, + validate: { + params: paramSchema, + query: querySchema, + }, + }, + router.handleLegacyErrors(async function ( + context: RequestHandlerContext, + req: KibanaRequest, TypeOf, unknown>, + res: KibanaResponseFactory + ): Promise { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + const { dateStart } = req.query; + const status = await alertsClient.getAlertStatus({ id, dateStart }); + return res.ok({ body: status }); + }) + ); +}; diff --git a/x-pack/plugins/alerts/server/routes/index.ts b/x-pack/plugins/alerts/server/routes/index.ts index f833a29c67bb9..4c6b1eb8e9b58 100644 --- a/x-pack/plugins/alerts/server/routes/index.ts +++ b/x-pack/plugins/alerts/server/routes/index.ts @@ -9,6 +9,7 @@ export { deleteAlertRoute } from './delete'; export { findAlertRoute } from './find'; export { getAlertRoute } from './get'; export { getAlertStateRoute } from './get_alert_state'; +export { getAlertStatusRoute } from './get_alert_status'; export { listAlertTypesRoute } from './list_alert_types'; export { updateAlertRoute } from './update'; export { enableAlertRoute } from './enable'; 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 4abe58de5a904..58b1fa4a123e1 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 @@ -224,7 +224,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); expect(eventLogger.logEvent).toHaveBeenCalledWith({ event: { action: 'execute', @@ -261,6 +261,25 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' created new instance: '1'", }); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + event: { + action: 'active-instance', + }, + kibana: { + alerting: { + instance_id: '1', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "test:1: 'alert-name' active instance: '1'", + }); expect(eventLogger.logEvent).toHaveBeenCalledWith({ event: { action: 'execute-action', @@ -345,7 +364,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -388,6 +407,27 @@ describe('Task Runner', () => { "message": "test:1: 'alert-name' created new instance: '1'", }, ], + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1'", + }, + ], Array [ Object { "event": Object { @@ -465,7 +505,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -508,6 +548,27 @@ describe('Task Runner', () => { "message": "test:1: 'alert-name' resolved instance: '2'", }, ], + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1'", + }, + ], ] `); }); 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 04fea58f250a3..89fb1a8196562 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -355,41 +355,48 @@ interface GenerateNewAndResolvedInstanceEventsParams { } function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) { + const { eventLogger, alertId, namespace } = params; const { currentAlertInstanceIds, originalAlertInstanceIds } = params; + const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds); const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds); + for (const id of resolvedIds) { + const message = `${params.alertLabel} resolved instance: '${id}'`; + logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + } + for (const id of newIds) { const message = `${params.alertLabel} created new instance: '${id}'`; logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message); } - for (const id of resolvedIds) { - const message = `${params.alertLabel} resolved instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + for (const id of currentAlertInstanceIds) { + const message = `${params.alertLabel} active instance: '${id}'`; + logInstanceEvent(id, EVENT_LOG_ACTIONS.activeInstance, message); } - function logInstanceEvent(id: string, action: string, message: string) { + function logInstanceEvent(instanceId: string, action: string, message: string) { const event: IEvent = { event: { action, }, kibana: { alerting: { - instance_id: id, + instance_id: instanceId, }, saved_objects: [ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', - id: params.alertId, - namespace: params.namespace, + id: alertId, + namespace, }, ], }, message, }; - params.eventLogger.logEvent(event); + eventLogger.logEvent(event); } } diff --git a/x-pack/plugins/event_log/server/event_log_start_service.test.ts b/x-pack/plugins/event_log/server/event_log_start_service.test.ts index 3bd5ef7c0b3ba..75af653a00834 100644 --- a/x-pack/plugins/event_log/server/event_log_start_service.test.ts +++ b/x-pack/plugins/event_log/server/event_log_start_service.test.ts @@ -30,6 +30,15 @@ describe('EventLogClientService', () => { expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { includedHiddenTypes: ['action', 'alert'], }); + + const [{ value: savedObjectsClient }] = savedObjectsService.getScopedClient.mock.results; + + expect(jest.requireMock('./event_log_client').EventLogClient).toHaveBeenCalledWith({ + esContext, + request, + savedObjectsClient, + spacesService: undefined, + }); }); }); }); diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index 25b1b95831b8a..7169aa6ff9baa 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -14,7 +14,10 @@ export { IEventLogClientService, IEvent, IValidatedEvent, + IEventLogClient, + QueryEventsBySavedObjectResult, SAVED_OBJECT_REL_PRIMARY, } from './types'; + export const config = { schema: ConfigSchema }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/event_log/server/mocks.ts b/x-pack/plugins/event_log/server/mocks.ts index 2f632a52d2f36..39ec9c42522dc 100644 --- a/x-pack/plugins/event_log/server/mocks.ts +++ b/x-pack/plugins/event_log/server/mocks.ts @@ -7,6 +7,8 @@ import { eventLogServiceMock } from './event_log_service.mock'; import { eventLogStartServiceMock } from './event_log_start_service.mock'; +export { eventLogClientMock } from './event_log_client.mock'; + export { eventLogServiceMock, eventLogStartServiceMock }; export { eventLoggerMock } from './event_logger.mock'; diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 1a37c4e58d079..8af36e221e520 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -12,6 +12,7 @@ export { IEvent, IValidatedEvent, EventSchema, ECS_VERSION } from '../generated/ import { IEvent } from '../generated/schemas'; import { FindOptionsType } from './event_log_client'; import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; +export { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; export const SAVED_OBJECT_REL_PRIMARY = 'primary'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 99d69602db137..636082656f1a4 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -74,6 +74,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", ] `); @@ -110,6 +111,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", "alerting:1.0.0-zeta1:alert-type/my-feature/create", "alerting:1.0.0-zeta1:alert-type/my-feature/delete", @@ -156,6 +158,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", "alerting:1.0.0-zeta1:alert-type/my-feature/create", "alerting:1.0.0-zeta1:alert-type/my-feature/delete", @@ -169,6 +172,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteInstance", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/find", ] `); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 42dd7794ba184..540b9e5c1e56e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; -const readOperations: string[] = ['get', 'getAlertState', 'find']; +const readOperations: string[] = ['get', 'getAlertState', 'getAlertStatus', 'find']; const writeOperations: string[] = [ 'create', 'delete', 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 35fdc3974a296..7dde344d06fb5 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 @@ -11,7 +11,7 @@ import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerts/common'; import { BASE_ALERT_API_PATH } from '../constants'; -import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; +import { Alert, AlertType, AlertWithoutId, AlertTaskState, AlertStatus } from '../../types'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`); @@ -48,6 +48,16 @@ export async function loadAlertState({ }); } +export async function loadAlertStatus({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/alert/${alertId}/status`); +} + export async function loadAlerts({ http, page, 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 dd2ee48b7a620..ff9b518a9f5b1 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 @@ -7,7 +7,7 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances'; -import { Alert, AlertTaskState, RawAlertInstance } from '../../../../types'; +import { Alert, AlertStatus, AlertInstanceStatus } from '../../../../types'; import { EuiBasicTable } from '@elastic/eui'; const fakeNow = new Date('2020-02-09T23:15:41.941Z'); @@ -34,26 +34,37 @@ jest.mock('../../../app_context', () => { describe('alert_instances', () => { it('render a list of alert instances', () => { const alert = mockAlert(); + const alertStatus = mockAlertStatus({ + instances: { + first_instance: { + status: 'OK', + muted: false, + }, + second_instance: { + status: 'OK', + muted: false, + }, + }, + }); - const alertState = mockAlertState(); const instances: AlertInstanceListItem[] = [ alertInstanceToListItem( fakeNow.getTime(), alert, 'first_instance', - alertState.alertInstances!.first_instance + alertStatus.instances.first_instance ), alertInstanceToListItem( fakeNow.getTime(), alert, 'second_instance', - alertState.alertInstances!.second_instance + alertStatus.instances.second_instance ), ]; expect( shallow( - + ) .find(EuiBasicTable) .prop('items') @@ -62,7 +73,7 @@ describe('alert_instances', () => { it('render a hidden field with duration epoch', () => { const alert = mockAlert(); - const alertState = mockAlertState(); + const alertStatus = mockAlertStatus(); expect( shallow( @@ -71,7 +82,7 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={alertState} + alertStatus={alertStatus} /> ) .find('[name="alertInstancesDurationEpoch"]') @@ -81,17 +92,15 @@ describe('alert_instances', () => { it('render all active alert instances', () => { const alert = mockAlert(); - const instances = { + const instances: Record = { ['us-central']: { - state: {}, - meta: { - lastScheduledActions: { - group: 'warning', - date: fake2MinutesAgo, - }, - }, + status: 'OK', + muted: false, + }, + ['us-east']: { + status: 'OK', + muted: false, }, - ['us-east']: {}, }; expect( shallow( @@ -99,8 +108,8 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={mockAlertState({ - alertInstances: instances, + alertStatus={mockAlertStatus({ + instances, })} /> ) @@ -116,6 +125,8 @@ describe('alert_instances', () => { const alert = mockAlert({ mutedInstanceIds: ['us-west', 'us-east'], }); + const instanceUsWest: AlertInstanceStatus = { status: 'OK', muted: false }; + const instanceUsEast: AlertInstanceStatus = { status: 'OK', muted: false }; expect( shallow( @@ -123,16 +134,25 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={mockAlertState({ - alertInstances: {}, + alertStatus={mockAlertStatus({ + instances: { + 'us-west': { + status: 'OK', + muted: false, + }, + 'us-east': { + status: 'OK', + muted: false, + }, + }, })} /> ) .find(EuiBasicTable) .prop('items') ).toEqual([ - alertInstanceToListItem(fakeNow.getTime(), alert, 'us-west'), - alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east'), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-west', instanceUsWest), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east', instanceUsEast), ]); }); }); @@ -141,13 +161,10 @@ describe('alertInstanceToListItem', () => { it('handles active instances', () => { const alert = mockAlert(); const start = fake2MinutesAgo; - const instance: RawAlertInstance = { - meta: { - lastScheduledActions: { - date: start, - group: 'default', - }, - }, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: false, + activeStartDate: fake2MinutesAgo.toISOString(), }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -164,13 +181,10 @@ describe('alertInstanceToListItem', () => { mutedInstanceIds: ['id'], }); const start = fake2MinutesAgo; - const instance: RawAlertInstance = { - meta: { - lastScheduledActions: { - date: start, - group: 'default', - }, - }, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: true, + activeStartDate: fake2MinutesAgo.toISOString(), }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -182,23 +196,11 @@ describe('alertInstanceToListItem', () => { }); }); - it('handles active instances with no meta', () => { + it('handles active instances with start date', () => { const alert = mockAlert(); - const instance: RawAlertInstance = {}; - - expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ - instance: 'id', - status: { label: 'Active', healthColor: 'primary' }, - start: undefined, - duration: 0, - isMuted: false, - }); - }); - - it('handles active instances with no lastScheduledActions', () => { - const alert = mockAlert(); - const instance: RawAlertInstance = { - meta: {}, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: false, }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -214,9 +216,13 @@ describe('alertInstanceToListItem', () => { const alert = mockAlert({ mutedInstanceIds: ['id'], }); - expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id')).toEqual({ + const instance: AlertInstanceStatus = { + status: 'OK', + muted: true, + }; + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', - status: { label: 'Inactive', healthColor: 'subdued' }, + status: { label: 'OK', healthColor: 'subdued' }, start: undefined, duration: 0, isMuted: true, @@ -247,23 +253,26 @@ function mockAlert(overloads: Partial = {}): Alert { }; } -function mockAlertState(overloads: Partial = {}): AlertTaskState { - return { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date(), - }, - }, +function mockAlertStatus(overloads: Partial = {}): AlertStatus { + const status: AlertStatus = { + id: 'alert-id', + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'alert-type-id', + consumer: 'alert-consumer', + status: 'OK', + muteAll: false, + throttle: '', + enabled: true, + errorMessages: [], + statusStartDate: fake2MinutesAgo.toISOString(), + statusEndDate: fakeNow.toISOString(), + instances: { + foo: { + status: 'OK', + muted: false, }, - second_instance: {}, }, - ...overloads, }; + return { ...status, ...overloads }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx index e239188659178..77a3b454a1820 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiHealth, EuiSpacer, EuiSwitch } from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; -import { padStart, difference, chunk } from 'lodash'; -import { Alert, AlertTaskState, RawAlertInstance, Pagination } from '../../../../types'; +import { padStart, chunk } from 'lodash'; +import { Alert, AlertStatus, AlertInstanceStatus, Pagination } from '../../../../types'; import { ComponentOpts as AlertApis, withBulkAlertOperations, @@ -21,7 +21,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; type AlertInstancesProps = { alert: Alert; readOnly: boolean; - alertState: AlertTaskState; + alertStatus: AlertStatus; requestRefresh: () => Promise; durationEpoch?: number; } & Pick; @@ -113,7 +113,7 @@ function durationAsString(duration: Duration): string { export function AlertInstances({ alert, readOnly, - alertState: { alertInstances = {} }, + alertStatus, muteAlertInstance, unmuteAlertInstance, requestRefresh, @@ -124,15 +124,10 @@ export function AlertInstances({ size: DEFAULT_SEARCH_PAGE_SIZE, }); - const mergedAlertInstances = [ - ...Object.entries(alertInstances).map(([instanceId, instance]) => - alertInstanceToListItem(durationEpoch, alert, instanceId, instance) - ), - ...difference(alert.mutedInstanceIds, Object.keys(alertInstances)).map((instanceId) => - alertInstanceToListItem(durationEpoch, alert, instanceId) - ), - ]; - const pageOfAlertInstances = getPage(mergedAlertInstances, pagination); + const alertInstances = Object.entries(alertStatus.instances).map(([instanceId, instance]) => + alertInstanceToListItem(durationEpoch, alert, instanceId, instance) + ); + const pageOfAlertInstances = getPage(alertInstances, pagination); const onMuteAction = async (instance: AlertInstanceListItem) => { await (instance.isMuted @@ -155,7 +150,7 @@ export function AlertInstances({ pagination={{ pageIndex: pagination.index, pageSize: pagination.size, - totalItemCount: mergedAlertInstances.length, + totalItemCount: alertInstances.length, }} onChange={({ page: changedPage }: { page: Pagination }) => { setPagination(changedPage); @@ -197,29 +192,27 @@ const ACTIVE_LABEL = i18n.translate( const INACTIVE_LABEL = i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive', - { defaultMessage: 'Inactive' } + { defaultMessage: 'OK' } ); -const durationSince = (durationEpoch: number, startTime?: number) => - startTime ? durationEpoch - startTime : 0; - export function alertInstanceToListItem( durationEpoch: number, alert: Alert, instanceId: string, - instance?: RawAlertInstance + instance: AlertInstanceStatus ): AlertInstanceListItem { - const isMuted = alert.mutedInstanceIds.findIndex((muted) => muted === instanceId) >= 0; + const isMuted = !!instance?.muted; + const status = + instance?.status === 'Active' + ? { label: ACTIVE_LABEL, healthColor: 'primary' } + : { label: INACTIVE_LABEL, healthColor: 'subdued' }; + const start = instance?.activeStartDate ? new Date(instance.activeStartDate) : undefined; + const duration = start ? durationEpoch - start.valueOf() : 0; return { instance: instanceId, - status: instance - ? { label: ACTIVE_LABEL, healthColor: 'primary' } - : { label: INACTIVE_LABEL, healthColor: 'subdued' }, - start: instance?.meta?.lastScheduledActions?.date, - duration: durationSince( - durationEpoch, - instance?.meta?.lastScheduledActions?.date?.getTime() ?? 0 - ), + status, + start, + duration, isMuted, }; } 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 975856beba556..61af8f5478521 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 @@ -7,17 +7,20 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { ToastsApi } from 'kibana/public'; -import { AlertInstancesRoute, getAlertState } from './alert_instances_route'; -import { Alert } from '../../../../types'; +import { AlertInstancesRoute, getAlertStatus } from './alert_instances_route'; +import { Alert, AlertStatus } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +const fakeNow = new Date('2020-02-09T23:15:41.941Z'); +const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); + jest.mock('../../../app_context', () => { const toastNotifications = jest.fn(); return { useAppDependencies: jest.fn(() => ({ toastNotifications })), }; }); -describe('alert_state_route', () => { +describe('alert_status_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); @@ -34,25 +37,25 @@ describe('getAlertState useEffect handler', () => { jest.clearAllMocks(); }); - it('fetches alert state', async () => { + it('fetches alert status', async () => { const alert = mockAlert(); - const alertState = mockAlertState(); - const { loadAlertState } = mockApis(); - const { setAlertState } = mockStateSetter(); + const alertStatus = mockAlertStatus(); + const { loadAlertStatus } = mockApis(); + const { setAlertStatus } = mockStateSetter(); - loadAlertState.mockImplementationOnce(async () => alertState); + loadAlertStatus.mockImplementationOnce(async () => alertStatus); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + await getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); - expect(loadAlertState).toHaveBeenCalledWith(alert.id); - expect(setAlertState).toHaveBeenCalledWith(alertState); + expect(loadAlertStatus).toHaveBeenCalledWith(alert.id); + expect(setAlertStatus).toHaveBeenCalledWith(alertStatus); }); - it('displays an error if the alert state isnt found', async () => { + it('displays an error if the alert status isnt found', async () => { const actionType = { id: '.server-log', name: 'Server log', @@ -69,34 +72,34 @@ describe('getAlertState useEffect handler', () => { ], }); - const { loadAlertState } = mockApis(); - const { setAlertState } = mockStateSetter(); + const { loadAlertStatus } = mockApis(); + const { setAlertStatus } = mockStateSetter(); - loadAlertState.mockImplementation(async () => { + loadAlertStatus.mockImplementation(async () => { throw new Error('OMG'); }); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + await getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load alert state: OMG', + title: 'Unable to load alert status: OMG', }); }); }); function mockApis() { return { - loadAlertState: jest.fn(), + loadAlertStatus: jest.fn(), requestRefresh: jest.fn(), }; } function mockStateSetter() { return { - setAlertState: jest.fn(), + setAlertStatus: jest.fn(), }; } @@ -123,22 +126,26 @@ function mockAlert(overloads: Partial = {}): Alert { }; } -function mockAlertState(overloads: Partial = {}): any { - return { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date(), - }, - }, +function mockAlertStatus(overloads: Partial = {}): any { + const status: AlertStatus = { + id: 'alert-id', + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'alert-type-id', + consumer: 'alert-consumer', + status: 'OK', + muteAll: false, + throttle: null, + enabled: true, + errorMessages: [], + statusStartDate: fake2MinutesAgo.toISOString(), + statusEndDate: fakeNow.toISOString(), + instances: { + foo: { + status: 'OK', + muted: false, }, - second_instance: {}, }, }; + return status; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index d8a7d18eb87a9..3afec45bcad64 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { ToastsApi } from 'kibana/public'; import React, { useState, useEffect } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { Alert, AlertTaskState } from '../../../../types'; +import { Alert, AlertStatus } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { ComponentOpts as AlertApis, @@ -16,33 +16,33 @@ import { } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; -type WithAlertStateProps = { +type WithAlertStatusProps = { alert: Alert; readOnly: boolean; requestRefresh: () => Promise; -} & Pick; +} & Pick; -export const AlertInstancesRoute: React.FunctionComponent = ({ +export const AlertInstancesRoute: React.FunctionComponent = ({ alert, readOnly, requestRefresh, - loadAlertState, + loadAlertStatus: loadAlertStatus, }) => { const { toastNotifications } = useAppDependencies(); - const [alertState, setAlertState] = useState(null); + const [alertStatus, setAlertStatus] = useState(null); useEffect(() => { - getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); // eslint-disable-next-line react-hooks/exhaustive-deps }, [alert]); - return alertState ? ( + return alertStatus ? ( ) : (
= ); }; -export async function getAlertState( +export async function getAlertStatus( alertId: string, - loadAlertState: AlertApis['loadAlertState'], - setAlertState: React.Dispatch>, + loadAlertStatus: AlertApis['loadAlertStatus'], + setAlertStatus: React.Dispatch>, toastNotifications: Pick ) { try { - const loadedState = await loadAlertState(alertId); - setAlertState(loadedState); + const loadedStatus = await loadAlertStatus(alertId); + setAlertStatus(loadedStatus); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage', { - defaultMessage: 'Unable to load alert state: {message}', + defaultMessage: 'Unable to load alert status: {message}', values: { message: e.message, }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 0c6f71120cc2e..fd8b35a96bdf0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,13 @@ import React from 'react'; -import { Alert, AlertType, AlertTaskState, AlertingFrameworkHealth } from '../../../../types'; +import { + Alert, + AlertType, + AlertTaskState, + AlertStatus, + AlertingFrameworkHealth, +} from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -22,6 +28,7 @@ import { unmuteAlertInstance, loadAlert, loadAlertState, + loadAlertStatus, loadAlertTypes, health, } from '../../../lib/alert_api'; @@ -51,6 +58,7 @@ export interface ComponentOpts { }>; loadAlert: (id: Alert['id']) => Promise; loadAlertState: (id: Alert['id']) => Promise; + loadAlertStatus: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; getHealth: () => Promise; } @@ -119,6 +127,7 @@ export function withBulkAlertOperations( deleteAlert={async (alert: Alert) => deleteAlerts({ http, ids: [alert.id] })} loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} + loadAlertStatus={async (alertId: Alert['id']) => loadAlertStatus({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} getHealth={async () => health({ http })} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a42a9f56a751f..0c0d99eed4e7b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -12,10 +12,20 @@ import { SanitizedAlert as Alert, AlertAction, AlertTaskState, + AlertStatus, + AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, } from '../../alerts/common'; -export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFrameworkHealth }; +export { + Alert, + AlertAction, + AlertTaskState, + AlertStatus, + AlertInstanceStatus, + RawAlertInstance, + AlertingFrameworkHealth, +}; export { ActionType }; export type ActionTypeIndex = Record; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index ebf639067518f..2c87cb4419351 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -310,23 +310,31 @@ export function defineAlertTypes( defaultActionGroupId: 'default', async executor(alertExecutorOptions: AlertExecutorOptions) { const { services, state, params } = alertExecutorOptions; - const pattern = params.pattern; - if (!Array.isArray(pattern)) throw new Error('pattern is not an array'); - if (pattern.length === 0) throw new Error('pattern is empty'); + const pattern = params.pattern as Record; + if (typeof pattern !== 'object') throw new Error('pattern is not an object'); + let maxPatternLength = 0; + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (!Array.isArray(instancePattern)) { + throw new Error(`pattern for instance ${instanceId} is not an array`); + } + maxPatternLength = Math.max(maxPatternLength, instancePattern.length); + } // get the pattern index, return if past it const patternIndex = state.patternIndex ?? 0; - if (patternIndex > pattern.length) { + if (patternIndex >= maxPatternLength) { return { patternIndex }; } // fire if pattern says to - if (pattern[patternIndex]) { - services.alertInstanceFactory('instance').scheduleActions('default'); + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (instancePattern[patternIndex]) { + services.alertInstanceFactory(instanceId).scheduleActions('default'); + } } return { - patternIndex: (patternIndex + 1) % pattern.length, + patternIndex: patternIndex + 1, }; }, }; diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 99f51ff244546..aebcd854514b2 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -25,7 +25,7 @@ export async function getEventLog(params: GetEventLogParams): Promise { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle getAlertStatus alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('get', 'test.noop', 'alertsFixture'), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const { id, statusStartDate, statusEndDate } = response.body; + expect(id).to.equal(createdAlert.id); + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, [ + 'id', + 'statusStartDate', + 'statusEndDate', + 'lastRun', + ]); + expect(stableBody).to.eql({ + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle getAlertStatus alert request appropriately when unauthorized', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('id', 'instances', 'errorMessages'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`shouldn't getAlertStatus for an alert from another space`, async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix('other')}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + expect(response.statusCode).to.eql(404); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + case 'global_read at space1': + case 'superuser at space1': + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [alert/${createdAlert.id}] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle getAlertStatus request appropriately when alert doesn't exist`, async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/1/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 4cd5f0805121c..45fa075a65978 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -16,6 +16,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./enable')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); + loadTestFile(require.resolve('./get_alert_status')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./mute_all')); loadTestFile(require.resolve('./mute_instance')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index 79d25d8d10436..a5dff437283ae 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -35,7 +35,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { .expect(200); // pattern of when the alert should fire - const pattern = [false, true, true]; + const pattern = { + instance: [false, true, true], + }; const response = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) @@ -70,7 +72,13 @@ export default function eventLogTests({ getService }: FtrProviderContext) { type: 'alert', id: alertId, provider: 'alerting', - actions: ['execute', 'execute-action', 'new-instance', 'resolved-instance'], + actions: [ + 'execute', + 'execute-action', + 'new-instance', + 'active-instance', + 'resolved-instance', + ], }); }); @@ -120,24 +128,27 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); break; case 'new-instance': - validateEvent(event, { - spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], - message: `test.patternFiring:${alertId}: 'abc' created new instance: 'instance'`, - }); + validateInstanceEvent(event, `created new instance: 'instance'`); break; case 'resolved-instance': - validateEvent(event, { - spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], - message: `test.patternFiring:${alertId}: 'abc' resolved instance: 'instance'`, - }); + validateInstanceEvent(event, `resolved instance: 'instance'`); + break; + case 'active-instance': + validateInstanceEvent(event, `active instance: 'instance'`); break; // this will get triggered as we add new event actions default: throw new Error(`unexpected event action "${event?.event?.action}"`); } } + + function validateInstanceEvent(event: IValidatedEvent, subMessage: string) { + validateEvent(event, { + spaceId: Spaces.space1.id, + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, + }); + } }); it('should generate events for execution errors', async () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts new file mode 100644 index 0000000000000..341313ce55c60 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts @@ -0,0 +1,261 @@ +/* + * 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 { omit } from 'lodash'; + +import { Spaces } from '../../scenarios'; +import { + getUrlPrefix, + ObjectRemover, + getTestAlertData, + AlertUtils, + getEventLog, +} from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetAlertStatusTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const retry = getService('retry'); + const alertUtils = new AlertUtils({ space: Spaces.space1, supertestWithoutAuth }); + + describe('getAlertStatus', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + it(`handles non-existant alert`, async () => { + await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/1/status`) + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + + it('handles no-op alert', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, ['statusStartDate', 'statusEndDate', 'lastRun']); + expect(stableBody).to.eql({ + id: createdAlert.id, + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + }); + + it('handles no-op alert without waiting for execution event', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, ['statusStartDate', 'statusEndDate', 'lastRun']); + expect(stableBody).to.eql({ + id: createdAlert.id, + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + }); + + it('handles dateStart parameter', async () => { + const dateStart = '2020-08-08T08:08:08.008Z'; + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${ + createdAlert.id + }/status?dateStart=${dateStart}` + ); + expect(response.status).to.eql(200); + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + expect(statusStartDate).to.be(dateStart); + }); + + it('handles invalid dateStart parameter', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const dateStart = 'X0X0-08-08T08:08:08.008Z'; + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${ + createdAlert.id + }/status?dateStart=${dateStart}` + ); + expect(response.status).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Invalid date for parameter dateStart: "X0X0-08-08T08:08:08.008Z"', + }); + }); + + it('handles muted instances', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await alertUtils.muteInstance(createdAlert.id, '1'); + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + expect(response.body.instances).to.eql({ + '1': { + status: 'OK', + muted: true, + }, + }); + }); + + it('handles alert errors', async () => { + const dateNow = Date.now(); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ alertTypeId: 'test.throw' })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + const { errorMessages } = response.body; + expect(errorMessages.length).to.be.greaterThan(0); + const errorMessage = errorMessages[0]; + expect(Date.parse(errorMessage.date)).to.be.greaterThan(dateNow); + expect(errorMessage.message).to.be('this alert is intended to fail'); + }); + + it('handles multi-instance status', async () => { + // pattern of when the alert should fire + const pattern = { + instanceA: [true, true, true, true], + instanceB: [true, true, false, false], + instanceC: [true, true, true, true], + }; + + 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' }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await alertUtils.muteInstance(createdAlert.id, 'instanceC'); + await alertUtils.muteInstance(createdAlert.id, 'instanceD'); + await waitForEvents(createdAlert.id, ['new-instance', 'resolved-instance']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + const actualInstances = response.body.instances; + const expectedInstances = { + instanceA: { + status: 'Active', + muted: false, + activeStartDate: actualInstances.instanceA.activeStartDate, + }, + instanceB: { + status: 'OK', + muted: false, + }, + instanceC: { + status: 'Active', + muted: true, + activeStartDate: actualInstances.instanceC.activeStartDate, + }, + instanceD: { + status: 'OK', + muted: true, + }, + }; + expect(actualInstances).to.eql(expectedInstances); + }); + }); + + async function waitForEvents(id: string, actions: string[]) { + await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} 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 a23f0fa835313..b927b563eb54a 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 @@ -16,6 +16,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); + loadTestFile(require.resolve('./get_alert_status')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./mute_all')); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index d86d272c1da8c..1579d041c9f58 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -361,7 +361,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // await first run to complete so we have an initial state await retry.try(async () => { - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); expect(Object.keys(alertInstances).length).to.eql(instances.length); }); }); @@ -373,15 +373,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const status = await alerting.alerts.getAlertStatus(alert.id); const dateOnAllInstancesFromApiResponse = mapValues( - alertInstances, - ({ - meta: { - lastScheduledActions: { date }, - }, - }) => date + status.instances, + (instance) => instance.activeStartDate ); log.debug( @@ -471,7 +467,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ).to.eql([ { instance: 'eu-east', - status: 'Inactive', + status: 'OK', start: '', duration: '', }, @@ -574,7 +570,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // await first run to complete so we have an initial state await retry.try(async () => { - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); expect(Object.keys(alertInstances).length).to.eql(instances.length); }); @@ -595,7 +591,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); const items = await pageObjects.alertDetailsUI.getAlertInstancesList(); expect(items.length).to.eql(PAGE_SIZE); @@ -608,7 +604,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); await pageObjects.alertDetailsUI.clickPaginationNextPage(); diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 23a4529139c53..c6fbdecf77f16 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -8,6 +8,21 @@ import axios, { AxiosInstance } from 'axios'; import util from 'util'; import { ToolingLog } from '@kbn/dev-utils'; +export interface AlertStatus { + status: string; + muted: boolean; + enabled: boolean; + lastRun?: string; + errorMessage?: string; + instances: Record; +} + +export interface AlertInstanceStatus { + status: string; + muted: boolean; + activeStartDate?: string; +} + export class Alerts { private log: ToolingLog; private axios: AxiosInstance; @@ -141,10 +156,10 @@ export class Alerts { this.log.debug(`deleted alert ${alert.id}`); } - public async getAlertState(id: string) { + public async getAlertStatus(id: string): Promise { this.log.debug(`getting alert ${id} state`); - const { data } = await this.axios.get(`/api/alerts/alert/${id}/state`); + const { data } = await this.axios.get(`/api/alerts/alert/${id}/status`); return data; }