[EventLog] Populate alert instances view with event log data (#68437)

resolves https://github.com/elastic/kibana/issues/57446

Adds a new API (AlertClient and HTTP endpoint) `getAlertStatus()` which returns
alert data calculated from the event log.
This commit is contained in:
Patrick Mueller 2020-08-14 08:34:26 -04:00 committed by GitHub
parent 7bd014abb3
commit 67e28ac8b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2014 additions and 208 deletions

View file

@ -26,6 +26,7 @@ Table of Contents
- [`GET /api/alerts/_find`: Find alerts](#get-apialertfind-find-alerts)
- [`GET /api/alerts/alert/{id}`: Get alert](#get-apialertid-get-alert)
- [`GET /api/alerts/alert/{id}/state`: Get alert state](#get-apialertidstate-get-alert-state)
- [`GET /api/alerts/alert/{id}/status`: Get alert status](#get-apialertidstate-get-alert-status)
- [`GET /api/alerts/list_alert_types`: List alert types](#get-apialerttypes-list-alert-types)
- [`PUT /api/alerts/alert/{id}`: Update alert](#put-apialertid-update-alert)
- [`POST /api/alerts/alert/{id}/_enable`: Enable an alert](#post-apialertidenable-enable-an-alert)
@ -504,6 +505,23 @@ Params:
|---|---|---|
|id|The id of the alert whose state you're trying to get.|string|
### `GET /api/alerts/alert/{id}/status`: Get alert status
Similar to the `GET state` call, but collects additional information from
the event log.
Params:
|Property|Description|Type|
|---|---|---|
|id|The id of the alert whose status you're trying to get.|string|
Query:
|Property|Description|Type|
|---|---|---|
|dateStart|The date to start looking for alert events in the event log. Either an ISO date string, or a duration string indicating time since now.|string|
### `GET /api/alerts/list_alert_types`: List alert types
No parameters.

View file

@ -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<string, AlertInstanceStatus>;
}
export interface AlertInstanceStatus {
status: AlertInstanceStatusValues;
muted: boolean;
activeStartDate?: string;
}

View file

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

View file

@ -25,6 +25,7 @@ const createAlertsClientMock = () => {
muteInstance: jest.fn(),
unmuteInstance: jest.fn(),
listAlertTypes: jest.fn(),
getAlertStatus: jest.fn(),
};
return mocked;
};

View file

@ -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<ConstructorOptions> = {
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<string, unknown> = {}): 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<RawAlert> = {
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<RawAlert> = {}): SavedObject<RawAlert> {
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([
{

View file

@ -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<CreateAPIKeyResult>;
invalidateAPIKey: (params: InvalidateAPIKeyParams) => Promise<InvalidateAPIKeyResult>;
getActionsClient: () => Promise<ActionsClient>;
getEventLogClient: () => Promise<IEventLogClient>;
}
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<string | null>;
@ -147,6 +159,7 @@ export class AlertsClient {
) => Promise<InvalidateAPIKeyResult>;
private readonly getActionsClient: () => Promise<ActionsClient>;
private readonly actionsAuthorization: ActionsAuthorization;
private readonly getEventLogClient: () => Promise<IEventLogClient>;
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<Alert> {
@ -269,6 +284,49 @@ export class AlertsClient {
}
}
public async getAlertStatus({ id, dateStart }: GetAlertStatusParams): Promise<AlertStatus> {
this.logger.debug(`getAlertStatus(): getting alert ${id}`);
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);
const eventLogClient = await this.getEventLogClient();
this.logger.debug(`getAlertStatus(): search the event log for alert ${id}`);
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 for alert ${id}: ${err.message}`
);
events = [];
}
return alertStatusFromEventLog({
alert,
events,
dateStart: parsedDateStart.toISOString(),
dateEnd: dateNow.toISOString(),
});
}
public async find({
options: { fields, ...options } = {},
}: { options?: FindOptions } = {}): Promise<FindResult> {
@ -283,7 +341,6 @@ export class AlertsClient {
? `${options.filter} and ${authorizationFilter}`
: authorizationFilter;
}
const {
page,
per_page: perPage,
@ -886,3 +943,24 @@ export class AlertsClient {
return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 });
}
}
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;
}

View file

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

View file

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

View file

@ -18,6 +18,7 @@ import { Space } from '../../../spaces/server';
export enum ReadOperations {
Get = 'get',
GetAlertState = 'getAlertState',
GetAlertStatus = 'getAlertStatus',
Find = 'find',
}

View file

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

View file

@ -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<string, AlertInstanceStatus>();
// 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<string, AlertInstanceStatus>,
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;
}

View file

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

View file

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

View file

@ -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 {
@ -189,6 +192,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);
@ -235,6 +239,7 @@ export class AlertingPlugin {
},
actions: plugins.actions,
features: plugins.features,
eventLog: plugins.eventLog,
});
const getAlertsClientWithRequest = (request: KibanaRequest) => {

View file

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

View file

@ -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<typeof paramSchema>, TypeOf<typeof querySchema>, unknown>,
res: KibanaResponseFactory
): Promise<IKibanaResponse> {
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 });
})
);
};

View file

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

View file

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

View file

@ -355,41 +355,53 @@ interface GenerateNewAndResolvedInstanceEventsParams {
}
function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) {
const { currentAlertInstanceIds, originalAlertInstanceIds } = params;
const {
eventLogger,
alertId,
namespace,
currentAlertInstanceIds,
originalAlertInstanceIds,
} = params;
const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds);
const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds);
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);
}
function logInstanceEvent(id: string, action: string, message: string) {
for (const id of newIds) {
const message = `${params.alertLabel} created new instance: '${id}'`;
logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message);
}
for (const id of currentAlertInstanceIds) {
const message = `${params.alertLabel} active instance: '${id}'`;
logInstanceEvent(id, EVENT_LOG_ACTIONS.activeInstance, message);
}
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);
}
}

View file

@ -28,6 +28,14 @@ describe('EventLogClientService', () => {
eventLogStartService.getClient(request);
const savedObjectGetter = savedObjectProviderRegistry.getProvidersClient(request);
expect(jest.requireMock('./event_log_client').EventLogClient).toHaveBeenCalledWith({
esContext,
request,
savedObjectGetter,
spacesService: undefined,
});
expect(savedObjectProviderRegistry.getProvidersClient).toHaveBeenCalledWith(request);
});
});

View file

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

View file

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

View file

@ -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';
import { SavedObjectProvider } from './saved_object_provider_registry';
export const SAVED_OBJECT_REL_PRIMARY = 'primary';

View file

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

View file

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

View file

@ -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<AlertType[]> {
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<AlertStatus> {
return await http.get(`${BASE_ALERT_API_PATH}/alert/${alertId}/status`);
}
export async function loadAlerts({
http,
page,

View file

@ -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(
<AlertInstances {...mockAPIs} alert={alert} alertState={alertState} readOnly={false} />
<AlertInstances {...mockAPIs} alert={alert} alertStatus={alertStatus} readOnly={false} />
)
.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<string, AlertInstanceStatus> = {
['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> = {}): Alert {
};
}
function mockAlertState(overloads: Partial<any> = {}): AlertTaskState {
return {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: new Date(),
},
},
function mockAlertStatus(overloads: Partial<AlertStatus> = {}): 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 };
}

View file

@ -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<void>;
durationEpoch?: number;
} & Pick<AlertApis, 'muteAlertInstance' | 'unmuteAlertInstance'>;
@ -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,
};
}

View file

@ -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> = {}): Alert {
};
}
function mockAlertState(overloads: Partial<any> = {}): any {
return {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: new Date(),
},
},
function mockAlertStatus(overloads: Partial<any> = {}): 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;
}

View file

@ -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<void>;
} & Pick<AlertApis, 'loadAlertState'>;
} & Pick<AlertApis, 'loadAlertStatus'>;
export const AlertInstancesRoute: React.FunctionComponent<WithAlertStateProps> = ({
export const AlertInstancesRoute: React.FunctionComponent<WithAlertStatusProps> = ({
alert,
readOnly,
requestRefresh,
loadAlertState,
loadAlertStatus: loadAlertStatus,
}) => {
const { toastNotifications } = useAppDependencies();
const [alertState, setAlertState] = useState<AlertTaskState | null>(null);
const [alertStatus, setAlertStatus] = useState<AlertStatus | null>(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 ? (
<AlertInstances
requestRefresh={requestRefresh}
alert={alert}
readOnly={readOnly}
alertState={alertState}
alertStatus={alertStatus}
/>
) : (
<div
@ -56,21 +56,21 @@ export const AlertInstancesRoute: React.FunctionComponent<WithAlertStateProps> =
);
};
export async function getAlertState(
export async function getAlertStatus(
alertId: string,
loadAlertState: AlertApis['loadAlertState'],
setAlertState: React.Dispatch<React.SetStateAction<AlertTaskState | null>>,
loadAlertStatus: AlertApis['loadAlertStatus'],
setAlertStatus: React.Dispatch<React.SetStateAction<AlertStatus | null>>,
toastNotifications: Pick<ToastsApi, 'addDanger'>
) {
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,
},

View file

@ -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<Alert>;
loadAlertState: (id: Alert['id']) => Promise<AlertTaskState>;
loadAlertStatus: (id: Alert['id']) => Promise<AlertStatus>;
loadAlertTypes: () => Promise<AlertType[]>;
getHealth: () => Promise<AlertingFrameworkHealth>;
}
@ -119,6 +127,7 @@ export function withBulkAlertOperations<T>(
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 })}
/>

View file

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

View file

@ -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<string, boolean[]>;
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,
};
},
};

View file

@ -25,7 +25,7 @@ export async function getEventLog(params: GetEventLogParams): Promise<IValidated
const actionsSet = new Set(actions);
const spacePrefix = getUrlPrefix(spaceId);
const url = `${spacePrefix}/api/event_log/${type}/${id}/_find`;
const url = `${spacePrefix}/api/event_log/${type}/${id}/_find?per_page=5000`;
const { body: result } = await supertest.get(url).expect(200);
if (!result.total) {

View file

@ -0,0 +1,202 @@
/*
* 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 {
getUrlPrefix,
ObjectRemover,
getTestAlertData,
getConsumerUnauthorizedErrorMessage,
getProducerUnauthorizedErrorMessage,
} from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { UserAtSpaceScenarios } from '../../scenarios';
// eslint-disable-next-line import/no-default-export
export default function createGetAlertStatusTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('getAlertStatus', () => {
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)}`);
}
});
});
}
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, AlertInstanceStatus>;
}
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<AlertStatus> {
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;
}