[Alerting] Return alert execution status rollup from _find API (#81819)

* wip

* wip

* Adding aggregation option to find function and using those results in UI

* Requesting aggregations from client instead of hard-coding in route

* alert_api test

* i18n fix

* Adding functional test

* Adding unit test for filters

* Splitting into two API endpoints

* Fixing test

* Fixing test

* Adding comment

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2020-11-03 07:26:44 -05:00 committed by GitHub
parent c2af3aa5d3
commit ae007c2e8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 905 additions and 34 deletions

View file

@ -46,6 +46,10 @@ export interface AlertAction {
params: AlertActionParams;
}
export interface AlertAggregations {
alertExecutionStatus: { [status: string]: number };
}
export interface Alert {
id: string;
enabled: boolean;

View file

@ -11,6 +11,7 @@ export type AlertsClientMock = jest.Mocked<Schema>;
const createAlertsClientMock = () => {
const mocked: AlertsClientMock = {
aggregate: jest.fn(),
create: jest.fn(),
get: jest.fn(),
getAlertState: jest.fn(),

View file

@ -27,6 +27,7 @@ import {
SanitizedAlert,
AlertTaskState,
AlertInstanceSummary,
AlertExecutionStatusValues,
} from '../types';
import { validateAlertTypeParams, alertExecutionStatusFromRaw } from '../lib';
import {
@ -98,10 +99,25 @@ export interface FindOptions extends IndexType {
filter?: string;
}
export interface AggregateOptions extends IndexType {
search?: string;
defaultSearchOperator?: 'AND' | 'OR';
searchFields?: string[];
hasReference?: {
type: string;
id: string;
};
filter?: string;
}
interface IndexType {
[key: string]: unknown;
}
interface AggregateResult {
alertExecutionStatus: { [status: string]: number };
}
export interface FindResult {
page: number;
perPage: number;
@ -400,6 +416,44 @@ export class AlertsClient {
};
}
public async aggregate({
options: { fields, ...options } = {},
}: { options?: AggregateOptions } = {}): Promise<AggregateResult> {
// Replace this when saved objects supports aggregations https://github.com/elastic/kibana/pull/64002
const alertExecutionStatus = await Promise.all(
AlertExecutionStatusValues.map(async (status: string) => {
const {
filter: authorizationFilter,
logSuccessfulAuthorization,
} = await this.authorization.getFindAuthorizationFilter();
const filter = options.filter
? `${options.filter} and alert.attributes.executionStatus.status:(${status})`
: `alert.attributes.executionStatus.status:(${status})`;
const { total } = await this.unsecuredSavedObjectsClient.find<RawAlert>({
...options,
filter:
(authorizationFilter && filter
? and([esKuery.fromKueryExpression(filter), authorizationFilter])
: authorizationFilter) ?? filter,
page: 1,
perPage: 0,
type: 'alert',
});
logSuccessfulAuthorization();
return { [status]: total };
})
);
return {
alertExecutionStatus: alertExecutionStatus.reduce(
(acc, curr: { [status: string]: number }) => Object.assign(acc, curr),
{}
),
};
}
public async delete({ id }: { id: string }) {
let taskIdToRemove: string | undefined | null;
let apiKeyToInvalidate: string | null = null;

View file

@ -0,0 +1,164 @@
/*
* 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 { AlertsClient, ConstructorOptions } from '../alerts_client';
import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { taskManagerMock } from '../../../../task_manager/server/mocks';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock';
import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks';
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertsAuthorization } from '../../authorization/alerts_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { getBeforeSetup, setGlobalDate } from './lib';
import { AlertExecutionStatusValues } from '../../types';
const taskManager = taskManagerMock.createStart();
const alertTypeRegistry = alertTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertsAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
const kibanaVersion = 'v7.10.0';
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
taskManager,
alertTypeRegistry,
unsecuredSavedObjectsClient,
authorization: (authorization as unknown) as AlertsAuthorization,
actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization,
spaceId: 'default',
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
invalidateAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
};
beforeEach(() => {
getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry);
});
setGlobalDate();
describe('aggregate()', () => {
const listedTypes = new Set([
{
actionGroups: [],
actionVariables: undefined,
defaultActionGroupId: 'default',
id: 'myType',
name: 'myType',
producer: 'myApp',
},
]);
beforeEach(() => {
authorization.getFindAuthorizationFilter.mockResolvedValue({
ensureAlertTypeIsAuthorized() {},
logSuccessfulAuthorization() {},
});
unsecuredSavedObjectsClient.find
.mockResolvedValueOnce({
total: 10,
per_page: 0,
page: 1,
saved_objects: [],
})
.mockResolvedValueOnce({
total: 8,
per_page: 0,
page: 1,
saved_objects: [],
})
.mockResolvedValueOnce({
total: 6,
per_page: 0,
page: 1,
saved_objects: [],
})
.mockResolvedValueOnce({
total: 4,
per_page: 0,
page: 1,
saved_objects: [],
})
.mockResolvedValueOnce({
total: 2,
per_page: 0,
page: 1,
saved_objects: [],
});
alertTypeRegistry.list.mockReturnValue(listedTypes);
authorization.filterByAlertTypeAuthorization.mockResolvedValue(
new Set([
{
id: 'myType',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },
},
},
])
);
});
test('calls saved objects client with given params to perform aggregation', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
const result = await alertsClient.aggregate({ options: {} });
expect(result).toMatchInlineSnapshot(`
Object {
"alertExecutionStatus": Object {
"active": 8,
"error": 6,
"ok": 10,
"pending": 4,
"unknown": 2,
},
}
`);
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(
AlertExecutionStatusValues.length
);
AlertExecutionStatusValues.forEach((status: string, ndx: number) => {
expect(unsecuredSavedObjectsClient.find.mock.calls[ndx]).toEqual([
{
fields: undefined,
filter: `alert.attributes.executionStatus.status:(${status})`,
page: 1,
perPage: 0,
type: 'alert',
},
]);
});
});
test('supports filters when aggregating', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.aggregate({ options: { filter: 'someTerm' } });
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(
AlertExecutionStatusValues.length
);
AlertExecutionStatusValues.forEach((status: string, ndx: number) => {
expect(unsecuredSavedObjectsClient.find.mock.calls[ndx]).toEqual([
{
fields: undefined,
filter: `someTerm and alert.attributes.executionStatus.status:(${status})`,
page: 1,
perPage: 0,
type: 'alert',
},
]);
});
});
});

View file

@ -33,6 +33,7 @@ import {
} from '../../../../src/core/server';
import {
aggregateAlertRoute,
createAlertRoute,
deleteAlertRoute,
findAlertRoute,
@ -190,6 +191,7 @@ export class AlertingPlugin {
// Routes
const router = core.http.createRouter();
// Register routes
aggregateAlertRoute(router, this.licenseState);
createAlertRoute(router, this.licenseState);
deleteAlertRoute(router, this.licenseState);
findAlertRoute(router, this.licenseState);

View file

@ -0,0 +1,141 @@
/*
* 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 { aggregateAlertRoute } from './aggregate';
import { httpServiceMock } from 'src/core/server/mocks';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { alertsClientMock } from '../alerts_client.mock';
const alertsClient = alertsClientMock.create();
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('aggregateAlertRoute', () => {
it('aggregate alerts with proper parameters', async () => {
const licenseState = mockLicenseState();
const router = httpServiceMock.createRouter();
aggregateAlertRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_aggregate"`);
const aggregateResult = {
alertExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
};
alertsClient.aggregate.mockResolvedValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ alertsClient },
{
query: {
default_search_operator: 'AND',
},
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"alertExecutionStatus": Object {
"active": 23,
"error": 2,
"ok": 15,
"pending": 1,
"unknown": 0,
},
},
}
`);
expect(alertsClient.aggregate).toHaveBeenCalledTimes(1);
expect(alertsClient.aggregate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"options": Object {
"defaultSearchOperator": "AND",
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: aggregateResult,
});
});
it('ensures the license allows aggregating alerts', async () => {
const licenseState = mockLicenseState();
const router = httpServiceMock.createRouter();
aggregateAlertRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
alertsClient.aggregate.mockResolvedValueOnce({
alertExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
});
const [context, req, res] = mockHandlerArguments(
{ alertsClient },
{
query: {
default_search_operator: 'OR',
},
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents aggregating alerts', async () => {
const licenseState = mockLicenseState();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('OMG');
});
aggregateAlertRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{},
{
query: {},
},
['ok']
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
});

View file

@ -0,0 +1,82 @@
/*
* 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';
import { renameKeys } from './lib/rename_keys';
import { FindOptions } from '../alerts_client';
// config definition
const querySchema = schema.object({
search: schema.maybe(schema.string()),
default_search_operator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], {
defaultValue: 'OR',
}),
search_fields: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])),
has_reference: schema.maybe(
// use nullable as maybe is currently broken
// in config-schema
schema.nullable(
schema.object({
type: schema.string(),
id: schema.string(),
})
)
),
filter: schema.maybe(schema.string()),
});
export const aggregateAlertRoute = (router: IRouter, licenseState: LicenseState) => {
router.get(
{
path: `${BASE_ALERT_API_PATH}/_aggregate`,
validate: {
query: querySchema,
},
},
router.handleLegacyErrors(async function (
context: RequestHandlerContext,
req: KibanaRequest<unknown, 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 query = req.query;
const renameMap = {
default_search_operator: 'defaultSearchOperator',
has_reference: 'hasReference',
search: 'search',
filter: 'filter',
};
const options = renameKeys<FindOptions, Record<string, unknown>>(renameMap, query);
if (query.search_fields) {
options.searchFields = Array.isArray(query.search_fields)
? query.search_fields
: [query.search_fields];
}
const aggregateResult = await alertsClient.aggregate({ options });
return res.ok({
body: aggregateResult,
});
})
);
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { aggregateAlertRoute } from './aggregate';
export { createAlertRoute } from './create';
export { deleteAlertRoute } from './delete';
export { findAlertRoute } from './find';

View file

@ -14,6 +14,7 @@ import {
disableAlert,
enableAlert,
loadAlert,
loadAlertAggregations,
loadAlerts,
loadAlertState,
loadAlertTypes,
@ -25,6 +26,7 @@ import {
muteAlertInstance,
unmuteAlertInstance,
health,
mapFiltersToKql,
} from './alert_api';
import uuid from 'uuid';
import { ALERTS_FEATURE_ID } from '../../../../alerts/common';
@ -351,6 +353,165 @@ describe('loadAlerts', () => {
});
});
describe('loadAlertAggregations', () => {
test('should call aggregate API with base parameters', async () => {
const resolvedValue = {
alertExecutionStatus: {
ok: 4,
active: 2,
error: 1,
pending: 1,
unknown: 0,
},
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertAggregations({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerts/_aggregate",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": undefined,
"search": undefined,
"search_fields": undefined,
},
},
]
`);
});
test('should call aggregate API with searchText', async () => {
const resolvedValue = {
alertExecutionStatus: {
ok: 4,
active: 2,
error: 1,
pending: 1,
unknown: 0,
},
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertAggregations({ http, searchText: 'apples' });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerts/_aggregate",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": undefined,
"search": "apples",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
test('should call aggregate API with actionTypesFilter', async () => {
const resolvedValue = {
alertExecutionStatus: {
ok: 4,
active: 2,
error: 1,
pending: 1,
unknown: 0,
},
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertAggregations({
http,
searchText: 'foo',
actionTypesFilter: ['action', 'type'],
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerts/_aggregate",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })",
"search": "foo",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
test('should call aggregate API with typesFilter', async () => {
const resolvedValue = {
alertExecutionStatus: {
ok: 4,
active: 2,
error: 1,
pending: 1,
unknown: 0,
},
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertAggregations({
http,
typesFilter: ['foo', 'bar'],
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerts/_aggregate",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "alert.attributes.alertTypeId:(foo or bar)",
"search": undefined,
"search_fields": undefined,
},
},
]
`);
});
test('should call aggregate API with actionTypesFilter and typesFilter', async () => {
const resolvedValue = {
alertExecutionStatus: {
ok: 4,
active: 2,
error: 1,
pending: 1,
unknown: 0,
},
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertAggregations({
http,
searchText: 'baz',
actionTypesFilter: ['action', 'type'],
typesFilter: ['foo', 'bar'],
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alerts/_aggregate",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "alert.attributes.alertTypeId:(foo or bar) and (alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })",
"search": "baz",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
});
describe('deleteAlerts', () => {
test('should call delete API for each alert', async () => {
const ids = ['1', '2', '3'];
@ -645,3 +806,61 @@ describe('health', () => {
`);
});
});
describe('mapFiltersToKql', () => {
test('should handle no filters', () => {
expect(mapFiltersToKql({})).toEqual([]);
});
test('should handle typesFilter', () => {
expect(
mapFiltersToKql({
typesFilter: ['type', 'filter'],
})
).toEqual(['alert.attributes.alertTypeId:(type or filter)']);
});
test('should handle actionTypesFilter', () => {
expect(
mapFiltersToKql({
actionTypesFilter: ['action', 'types', 'filter'],
})
).toEqual([
'(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })',
]);
});
test('should handle alertStatusesFilter', () => {
expect(
mapFiltersToKql({
alertStatusesFilter: ['alert', 'statuses', 'filter'],
})
).toEqual(['alert.attributes.executionStatus.status:(alert or statuses or filter)']);
});
test('should handle typesFilter and actionTypesFilter', () => {
expect(
mapFiltersToKql({
typesFilter: ['type', 'filter'],
actionTypesFilter: ['action', 'types', 'filter'],
})
).toEqual([
'alert.attributes.alertTypeId:(type or filter)',
'(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })',
]);
});
test('should handle typesFilter, actionTypesFilter and alertStatusesFilter', () => {
expect(
mapFiltersToKql({
typesFilter: ['type', 'filter'],
actionTypesFilter: ['action', 'types', 'filter'],
alertStatusesFilter: ['alert', 'statuses', 'filter'],
})
).toEqual([
'alert.attributes.alertTypeId:(type or filter)',
'(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })',
'alert.attributes.executionStatus.status:(alert or statuses or filter)',
]);
});
});

View file

@ -11,7 +11,14 @@ 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, AlertUpdates, AlertTaskState, AlertInstanceSummary } from '../../types';
import {
Alert,
AlertAggregations,
AlertType,
AlertUpdates,
AlertTaskState,
AlertInstanceSummary,
} from '../../types';
export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise<AlertType[]> {
return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`);
@ -58,6 +65,36 @@ export async function loadAlertInstanceSummary({
return await http.get(`${BASE_ALERT_API_PATH}/alert/${alertId}/_instance_summary`);
}
export const mapFiltersToKql = ({
typesFilter,
actionTypesFilter,
alertStatusesFilter,
}: {
typesFilter?: string[];
actionTypesFilter?: string[];
alertStatusesFilter?: string[];
}): string[] => {
const filters = [];
if (typesFilter && typesFilter.length) {
filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`);
}
if (actionTypesFilter && actionTypesFilter.length) {
filters.push(
[
'(',
actionTypesFilter
.map((id) => `alert.attributes.actions:{ actionTypeId:${id} }`)
.join(' OR '),
')',
].join('')
);
}
if (alertStatusesFilter && alertStatusesFilter.length) {
filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`);
}
return filters;
};
export async function loadAlerts({
http,
page,
@ -78,24 +115,7 @@ export async function loadAlerts({
total: number;
data: Alert[];
}> {
const filters = [];
if (typesFilter && typesFilter.length) {
filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`);
}
if (actionTypesFilter && actionTypesFilter.length) {
filters.push(
[
'(',
actionTypesFilter
.map((id) => `alert.attributes.actions:{ actionTypeId:${id} }`)
.join(' OR '),
')',
].join('')
);
}
if (alertStatusesFilter && alertStatusesFilter.length) {
filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`);
}
const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter });
return await http.get(`${BASE_ALERT_API_PATH}/_find`, {
query: {
page: page.index + 1,
@ -110,6 +130,30 @@ export async function loadAlerts({
});
}
export async function loadAlertAggregations({
http,
searchText,
typesFilter,
actionTypesFilter,
alertStatusesFilter,
}: {
http: HttpSetup;
searchText?: string;
typesFilter?: string[];
actionTypesFilter?: string[];
alertStatusesFilter?: string[];
}): Promise<AlertAggregations> {
const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter });
return await http.get(`${BASE_ALERT_API_PATH}/_aggregate`, {
query: {
search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined,
search: searchText,
filter: filters.length ? filters.join(' and ') : undefined,
default_search_operator: 'AND',
},
});
}
export async function deleteAlerts({
ids,
http,

View file

@ -40,7 +40,12 @@ import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed
import { TypeFilter } from './type_filter';
import { ActionTypeFilter } from './action_type_filter';
import { AlertStatusFilter, getHealthColor } from './alert_status_filter';
import { loadAlerts, loadAlertTypes, deleteAlerts } from '../../../lib/alert_api';
import {
loadAlerts,
loadAlertAggregations,
loadAlertTypes,
deleteAlerts,
} from '../../../lib/alert_api';
import { loadActionTypes } from '../../../lib/action_connector_api';
import { hasExecuteActionsCapability } from '../../../lib/capabilities';
import { routeToAlertDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants';
@ -178,7 +183,7 @@ export const AlertsList: React.FunctionComponent = () => {
actionTypesFilter,
alertStatusesFilter,
});
await loadAlertsTotalStatuses();
await loadAlertAggs();
setAlertsState({
isLoading: false,
data: alertsResponse.data,
@ -202,21 +207,18 @@ export const AlertsList: React.FunctionComponent = () => {
}
}
async function loadAlertsTotalStatuses() {
let alertsStatuses = {};
async function loadAlertAggs() {
try {
AlertExecutionStatusValues.forEach(async (status: string) => {
const alertsTotalResponse = await loadAlerts({
http,
page: { index: 0, size: 0 },
searchText,
typesFilter,
actionTypesFilter,
alertStatusesFilter: [status],
});
setAlertsStatusesTotal({ ...alertsStatuses, [status]: alertsTotalResponse.total });
alertsStatuses = { ...alertsStatuses, [status]: alertsTotalResponse.total };
const alertsAggs = await loadAlertAggregations({
http,
searchText,
typesFilter,
actionTypesFilter,
alertStatusesFilter,
});
if (alertsAggs?.alertExecutionStatus) {
setAlertsStatusesTotal(alertsAggs.alertExecutionStatus);
}
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(

View file

@ -12,6 +12,7 @@ import { TypeRegistry } from './application/type_registry';
import {
SanitizedAlert as Alert,
AlertAction,
AlertAggregations,
AlertTaskState,
AlertInstanceSummary,
AlertInstanceStatus,
@ -21,6 +22,7 @@ import {
export {
Alert,
AlertAction,
AlertAggregations,
AlertTaskState,
AlertInstanceSummary,
AlertInstanceStatus,

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { Spaces } from '../../scenarios';
import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function createAggregateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('aggregate', () => {
const objectRemover = new ObjectRemover(supertest);
afterEach(() => objectRemover.removeAll());
it('should aggregate when there are no alerts', async () => {
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/api/alerts/_aggregate`
);
expect(response.status).to.eql(200);
expect(response.body).to.eql({
alertExecutionStatus: {
ok: 0,
active: 0,
error: 0,
pending: 0,
unknown: 0,
},
});
});
it('should aggregate alert status totals', async () => {
const NumOkAlerts = 4;
const NumActiveAlerts = 1;
const NumErrorAlerts = 2;
await Promise.all(
[...Array(NumOkAlerts)].map(async () => {
const okAlertId = await createTestAlert(
{
alertTypeId: 'test.noop',
schedule: { interval: '1s' },
},
'ok'
);
objectRemover.add(Spaces.space1.id, okAlertId, 'alert', 'alerts');
})
);
await Promise.all(
[...Array(NumActiveAlerts)].map(async () => {
const activeAlertId = await createTestAlert(
{
alertTypeId: 'test.patternFiring',
schedule: { interval: '1s' },
params: {
pattern: { instance: new Array(100).fill(true) },
},
},
'active'
);
objectRemover.add(Spaces.space1.id, activeAlertId, 'alert', 'alerts');
})
);
await Promise.all(
[...Array(NumErrorAlerts)].map(async () => {
const activeAlertId = await createTestAlert(
{
alertTypeId: 'test.throw',
schedule: { interval: '1s' },
},
'error'
);
objectRemover.add(Spaces.space1.id, activeAlertId, 'alert', 'alerts');
})
);
// Adding delay to allow ES refresh cycle to run. Even when the waitForStatus
// calls are successful, the call to aggregate may return stale totals if called
// too early.
await delay(1000);
const reponse = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/api/alerts/_aggregate`
);
expect(reponse.status).to.eql(200);
expect(reponse.body).to.eql({
alertExecutionStatus: {
ok: NumOkAlerts,
active: NumActiveAlerts,
error: NumErrorAlerts,
pending: 0,
unknown: 0,
},
});
});
});
const WaitForStatusIncrement = 500;
async function waitForStatus(
id: string,
statuses: Set<string>,
waitMillis: number = 10000
): Promise<Record<string, any>> {
if (waitMillis < 0) {
expect().fail(`waiting for alert ${id} statuses ${Array.from(statuses)} timed out`);
}
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${id}`
);
expect(response.status).to.eql(200);
const { executionStatus } = response.body || {};
const { status } = executionStatus || {};
const message = `waitForStatus(${Array.from(statuses)}): got ${JSON.stringify(
executionStatus
)}`;
if (statuses.has(status)) {
return executionStatus;
}
// eslint-disable-next-line no-console
console.log(`${message}, retrying`);
await delay(WaitForStatusIncrement);
return await waitForStatus(id, statuses, waitMillis - WaitForStatusIncrement);
}
async function delay(millis: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, millis));
}
async function createTestAlert(testAlertOverrides = {}, status: string) {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send(getTestAlertData(testAlertOverrides))
.expect(200);
await waitForStatus(createdAlert.id, new Set([status]));
return createdAlert.id;
}
}

View file

@ -13,6 +13,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
before(async () => buildUp(getService));
after(async () => tearDown(getService));
loadTestFile(require.resolve('./aggregate'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./disable'));