mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
7bd014abb3
commit
67e28ac8b4
41 changed files with 2014 additions and 208 deletions
|
@ -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.
|
||||
|
|
31
x-pack/plugins/alerts/common/alert_status.ts
Normal file
31
x-pack/plugins/alerts/common/alert_status.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -25,6 +25,7 @@ const createAlertsClientMock = () => {
|
|||
muteInstance: jest.fn(),
|
||||
unmuteInstance: jest.fn(),
|
||||
listAlertTypes: jest.fn(),
|
||||
getAlertStatus: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -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([
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { Space } from '../../../spaces/server';
|
|||
export enum ReadOperations {
|
||||
Get = 'get',
|
||||
GetAlertState = 'getAlertState',
|
||||
GetAlertStatus = 'getAlertStatus',
|
||||
Find = 'find',
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
123
x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts
Normal file
123
x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts
Normal 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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
27
x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts
Normal file
27
x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts
Normal 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);
|
||||
}
|
|
@ -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) => {
|
||||
|
|
105
x-pack/plugins/alerts/server/routes/get_alert_status.test.ts
Normal file
105
x-pack/plugins/alerts/server/routes/get_alert_status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
52
x-pack/plugins/alerts/server/routes/get_alert_status.ts
Normal file
52
x-pack/plugins/alerts/server/routes/get_alert_status.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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'",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
`);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue