[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:
parent
c2af3aa5d3
commit
ae007c2e8a
|
@ -46,6 +46,10 @@ export interface AlertAction {
|
|||
params: AlertActionParams;
|
||||
}
|
||||
|
||||
export interface AlertAggregations {
|
||||
alertExecutionStatus: { [status: string]: number };
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
141
x-pack/plugins/alerts/server/routes/aggregate.test.ts
Normal file
141
x-pack/plugins/alerts/server/routes/aggregate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
82
x-pack/plugins/alerts/server/routes/aggregate.ts
Normal file
82
x-pack/plugins/alerts/server/routes/aggregate.ts
Normal 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,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
|
|
Loading…
Reference in a new issue