[RAM] Add the Logs tab to Rules and Connectors UI (#138852)

* Add initial logs tab, permissions checks still broken

* Filter global event logs by permissions

* Finalize table in logs list tab

* Create new function without wildcard id

* Fix error flyout

* Fix rule_id test

* Add test for getGlobalExecutionLogWithAuth

* Fix typechecks

* Fix jest types

* Fix types and tests

* Fix aggregation test

* Fix aggregation test

* Fix mocks in jest test

* Fix localstorage test

* Add rule.id to OutcomeAndMessage agg

* Move authfilter to query, move ruleid agg to outcomeandmessage

* Memoize rule name onclick

* Add new route for global execution logs

* Remove * from getruleexecutionlog route

* Fix types and jest

* Fix types

* Fix jest

* Fix nits

* Add cluster client adapter unti tests

* Add unit test for event log client

* Update get execution log aggregation test

* Remove sort options and hide option from Rule column

* Add functional test for global execution logs

Co-authored-by: Faisal Kanout <faisal.kanout@elastic.co>
This commit is contained in:
Zacqary Adam Xeper 2022-09-09 02:59:11 -05:00 committed by GitHub
parent 12466d8b17
commit a1f2c483fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2305 additions and 420 deletions

View file

@ -48,6 +48,7 @@ export interface IExecutionLog {
es_search_duration_ms: number;
schedule_delay_ms: number;
timed_out: boolean;
rule_id: string;
}
export interface IExecutionErrors {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { fromKueryExpression } from '@kbn/es-query';
import {
getNumExecutions,
getExecutionLogAggregation,
@ -271,7 +272,421 @@ describe('getExecutionLogAggregation', () => {
top_hits: {
size: 1,
_source: {
includes: ['event.outcome', 'message', 'error.message', 'kibana.version'],
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'rule.id',
],
},
},
},
},
},
timeoutMessage: {
filter: {
bool: {
must: [
{ match: { 'event.action': 'execute-timeout' } },
{ match: { 'event.provider': 'alerting' } },
],
},
},
},
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a string', () => {
expect(
getExecutionLogAggregation({
page: 2,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }],
filter: 'test:test',
})
).toEqual({
excludeExecuteStart: {
filter: {
bool: {
must_not: [
{
term: {
'event.action': 'execute-start',
},
},
],
},
},
aggs: {
executionUuidCardinality: {
aggs: {
executionUuidCardinality: {
cardinality: { field: 'kibana.alert.rule.execution.uuid' },
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
{
match: {
'event.provider': 'alerting',
},
},
],
},
},
],
},
},
},
executionUuid: {
terms: {
field: 'kibana.alert.rule.execution.uuid',
size: 1000,
order: [
{ 'ruleExecution>executeStartTime': 'asc' },
{ 'ruleExecution>executionDuration': 'desc' },
],
},
aggs: {
executionUuidSorted: {
bucket_sort: {
sort: [
{ 'ruleExecution>executeStartTime': { order: 'asc' } },
{ 'ruleExecution>executionDuration': { order: 'desc' } },
],
from: 10,
size: 10,
gap_policy: 'insert_zeros',
},
},
actionExecution: {
filter: {
bool: {
must: [
{ match: { 'event.action': 'execute' } },
{ match: { 'event.provider': 'actions' } },
],
},
},
aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } },
},
minExecutionUuidBucket: {
bucket_selector: {
buckets_path: {
count: 'ruleExecution._count',
},
script: {
source: 'params.count > 0',
},
},
},
ruleExecution: {
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
bool: {
must: [
{ match: { 'event.action': 'execute' } },
{ match: { 'event.provider': 'alerting' } },
],
},
},
],
},
},
aggs: {
executeStartTime: { min: { field: 'event.start' } },
scheduleDelay: {
max: {
field: 'kibana.task.schedule_delay',
},
},
totalSearchDuration: {
max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' },
},
esSearchDuration: {
max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' },
},
numTriggeredActions: {
max: {
field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions',
},
},
numGeneratedActions: {
max: {
field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions',
},
},
numActiveAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.active',
},
},
numRecoveredAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered',
},
},
numNewAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.new',
},
},
executionDuration: { max: { field: 'event.duration' } },
outcomeAndMessage: {
top_hits: {
size: 1,
_source: {
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'rule.id',
],
},
},
},
},
},
timeoutMessage: {
filter: {
bool: {
must: [
{ match: { 'event.action': 'execute-timeout' } },
{ match: { 'event.provider': 'alerting' } },
],
},
},
},
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a KueryNode', () => {
expect(
getExecutionLogAggregation({
page: 2,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }],
filter: fromKueryExpression('test:test'),
})
).toEqual({
excludeExecuteStart: {
filter: {
bool: {
must_not: [
{
term: {
'event.action': 'execute-start',
},
},
],
},
},
aggs: {
executionUuidCardinality: {
aggs: {
executionUuidCardinality: {
cardinality: { field: 'kibana.alert.rule.execution.uuid' },
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
{
match: {
'event.provider': 'alerting',
},
},
],
},
},
],
},
},
},
executionUuid: {
terms: {
field: 'kibana.alert.rule.execution.uuid',
size: 1000,
order: [
{ 'ruleExecution>executeStartTime': 'asc' },
{ 'ruleExecution>executionDuration': 'desc' },
],
},
aggs: {
executionUuidSorted: {
bucket_sort: {
sort: [
{ 'ruleExecution>executeStartTime': { order: 'asc' } },
{ 'ruleExecution>executionDuration': { order: 'desc' } },
],
from: 10,
size: 10,
gap_policy: 'insert_zeros',
},
},
actionExecution: {
filter: {
bool: {
must: [
{ match: { 'event.action': 'execute' } },
{ match: { 'event.provider': 'actions' } },
],
},
},
aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } },
},
minExecutionUuidBucket: {
bucket_selector: {
buckets_path: {
count: 'ruleExecution._count',
},
script: {
source: 'params.count > 0',
},
},
},
ruleExecution: {
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
bool: {
must: [
{ match: { 'event.action': 'execute' } },
{ match: { 'event.provider': 'alerting' } },
],
},
},
],
},
},
aggs: {
executeStartTime: { min: { field: 'event.start' } },
scheduleDelay: {
max: {
field: 'kibana.task.schedule_delay',
},
},
totalSearchDuration: {
max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' },
},
esSearchDuration: {
max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' },
},
numTriggeredActions: {
max: {
field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions',
},
},
numGeneratedActions: {
max: {
field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions',
},
},
numActiveAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.active',
},
},
numRecoveredAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered',
},
},
numNewAlerts: {
max: {
field: 'kibana.alert.rule.execution.metrics.alert_counts.new',
},
},
executionDuration: { max: { field: 'event.duration' } },
outcomeAndMessage: {
top_hits: {
size: 1,
_source: {
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'rule.id',
],
},
},
},
@ -361,6 +776,7 @@ describe('formatExecutionLogResult', () => {
_id: 'S4wIZX8B8TGQpG7XQZns',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -444,6 +860,8 @@ describe('formatExecutionLogResult', () => {
_id: 'a4wIZX8B8TGQpG7Xwpnz',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -521,6 +939,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -541,6 +960,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
});
@ -595,6 +1015,7 @@ describe('formatExecutionLogResult', () => {
_id: 'S4wIZX8B8TGQpG7XQZns',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'failure',
},
@ -681,6 +1102,7 @@ describe('formatExecutionLogResult', () => {
_id: 'a4wIZX8B8TGQpG7Xwpnz',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -758,6 +1180,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -778,6 +1201,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
});
@ -832,6 +1256,7 @@ describe('formatExecutionLogResult', () => {
_id: 'dJkWa38B1ylB1EvsAckB',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -910,6 +1335,7 @@ describe('formatExecutionLogResult', () => {
_id: 'a4wIZX8B8TGQpG7Xwpnz',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -987,6 +1413,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: true,
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -1007,6 +1434,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
});
@ -1061,6 +1489,7 @@ describe('formatExecutionLogResult', () => {
_id: '7xKcb38BcntAq5ycFwiu',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -1144,6 +1573,7 @@ describe('formatExecutionLogResult', () => {
_id: 'zRKbb38BcntAq5ycOwgk',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -1221,6 +1651,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '61bb867b-661a-471f-bf92-23471afa10b3',
@ -1241,6 +1672,7 @@ describe('formatExecutionLogResult', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3133,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { KueryNode } from '@kbn/core-saved-objects-api-server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import Boom from '@hapi/boom';
import { flatMap, get } from 'lodash';
@ -15,6 +16,7 @@ import { IExecutionLog, IExecutionLogResult } from '../../common';
const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions
const RULE_ID_FIELD = 'rule.id';
const PROVIDER_FIELD = 'event.provider';
const START_FIELD = 'event.start';
const ACTION_FIELD = 'event.action';
@ -80,7 +82,7 @@ interface ExcludeExecuteStartAggResult extends estypes.AggregationsAggregateBase
};
}
export interface IExecutionLogAggOptions {
filter?: string;
filter?: string | KueryNode;
page: number;
perPage: number;
sort: estypes.Sort;
@ -129,7 +131,8 @@ export function getExecutionLogAggregation({
let dslFilterQuery: estypes.QueryDslBoolQuery['filter'];
try {
dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : undefined;
const filterKueryNode = typeof filter === 'string' ? fromKueryExpression(filter) : filter;
dslFilterQuery = filter ? toElasticsearchQuery(filterKueryNode) : undefined;
} catch (err) {
throw Boom.badRequest(`Invalid kuery syntax for filter ${filter}`);
}
@ -256,7 +259,13 @@ export function getExecutionLogAggregation({
top_hits: {
size: 1,
_source: {
includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD, VERSION_FIELD],
includes: [
OUTCOME_FIELD,
MESSAGE_FIELD,
ERROR_MESSAGE_FIELD,
VERSION_FIELD,
RULE_ID_FIELD,
],
},
},
},
@ -325,6 +334,8 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio
? `${outcomeAndMessage?.message ?? ''} - ${outcomeAndMessage?.error?.message ?? ''}`
: outcomeAndMessage?.message ?? '';
const version = outcomeAndMessage ? outcomeAndMessage?.kibana?.version ?? '' : '';
const ruleId = outcomeAndMessage ? outcomeAndMessage?.rule?.id ?? '' : '';
return {
id: bucket?.key ?? '',
timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '',
@ -343,6 +354,7 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio
es_search_duration_ms: bucket?.ruleExecution?.esSearchDuration?.value ?? 0,
schedule_delay_ms: scheduleDelayUs / Millis2Nanos,
timed_out: timedOut,
rule_id: ruleId,
};
}

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getGlobalExecutionLogRoute } from './get_global_execution_logs';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { IExecutionLogResult } from '../../common';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('getRuleExecutionLogRoute', () => {
const dateString = new Date().toISOString();
const mockedExecutionLog: IExecutionLogResult = {
total: 374,
data: [
{
id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9',
timestamp: '2022-03-07T15:38:32.617Z',
duration_ms: 1056,
status: 'success',
message:
"rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'",
version: '8.2.0',
num_active_alerts: 5,
num_new_alerts: 5,
num_recovered_alerts: 0,
num_triggered_actions: 5,
num_generated_actions: 5,
num_succeeded_actions: 5,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
timestamp: '2022-03-07T15:39:05.604Z',
duration_ms: 1165,
status: 'success',
message:
"rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'",
version: '8.2.0',
num_active_alerts: 5,
num_new_alerts: 5,
num_recovered_alerts: 5,
num_triggered_actions: 5,
num_generated_actions: 5,
num_succeeded_actions: 5,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3008,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
};
it('gets global execution logs', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getGlobalExecutionLogRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/_global_execution_logs"`);
rulesClient.getGlobalExecutionLogWithAuth.mockResolvedValue(mockedExecutionLog);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
date_start: dateString,
per_page: 10,
page: 1,
sort: [{ timestamp: { order: 'desc' } }],
},
},
['ok']
);
await handler(context, req, res);
expect(rulesClient.getGlobalExecutionLogWithAuth).toHaveBeenCalledTimes(1);
expect(rulesClient.getGlobalExecutionLogWithAuth.mock.calls[0]).toEqual([
{
dateStart: dateString,
page: 1,
perPage: 10,
sort: [{ timestamp: { order: 'desc' } }],
},
]);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { GetGlobalExecutionLogParams } from '../rules_client';
import { RewriteRequestCase, verifyAccessAndContext } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]);
const sortFieldSchema = schema.oneOf([
schema.object({ timestamp: schema.object({ order: sortOrderSchema }) }),
schema.object({ execution_duration: schema.object({ order: sortOrderSchema }) }),
schema.object({ total_search_duration: schema.object({ order: sortOrderSchema }) }),
schema.object({ es_search_duration: schema.object({ order: sortOrderSchema }) }),
schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }),
schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }),
schema.object({ num_generated_actions: schema.object({ order: sortOrderSchema }) }),
schema.object({ num_active_alerts: schema.object({ order: sortOrderSchema }) }),
schema.object({ num_recovered_alerts: schema.object({ order: sortOrderSchema }) }),
schema.object({ num_new_alerts: schema.object({ order: sortOrderSchema }) }),
]);
const sortFieldsSchema = schema.arrayOf(sortFieldSchema, {
defaultValue: [{ timestamp: { order: 'desc' } }],
});
const querySchema = schema.object({
date_start: schema.string(),
date_end: schema.maybe(schema.string()),
filter: schema.maybe(schema.string()),
per_page: schema.number({ defaultValue: 10, min: 1 }),
page: schema.number({ defaultValue: 1, min: 1 }),
sort: sortFieldsSchema,
});
const rewriteReq: RewriteRequestCase<GetGlobalExecutionLogParams> = ({
date_start: dateStart,
date_end: dateEnd,
per_page: perPage,
...rest
}) => ({
...rest,
dateStart,
dateEnd,
perPage,
});
export const getGlobalExecutionLogRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/_global_execution_logs`,
validate: {
query: querySchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = (await context.alerting).getRulesClient();
return res.ok({
body: await rulesClient.getGlobalExecutionLogWithAuth(rewriteReq(req.query)),
});
})
)
);
};

View file

@ -46,6 +46,7 @@ describe('getRuleExecutionLogRoute', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -66,6 +67,7 @@ describe('getRuleExecutionLogRoute', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3008,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
};

View file

@ -21,6 +21,7 @@ import { enableRuleRoute } from './enable_rule';
import { findRulesRoute, findInternalRulesRoute } from './find_rules';
import { getRuleAlertSummaryRoute } from './get_rule_alert_summary';
import { getRuleExecutionLogRoute } from './get_rule_execution_log';
import { getGlobalExecutionLogRoute } from './get_global_execution_logs';
import { getActionErrorLogRoute } from './get_action_error_log';
import { getRuleStateRoute } from './get_rule_state';
import { healthRoute } from './health';
@ -59,6 +60,7 @@ export function defineRoutes(opts: RouteOptions) {
findInternalRulesRoute(router, licenseState, usageCounter);
getRuleAlertSummaryRoute(router, licenseState);
getRuleExecutionLogRoute(router, licenseState);
getGlobalExecutionLogRoute(router, licenseState);
getActionErrorLogRoute(router, licenseState);
getRuleStateRoute(router, licenseState);
healthRoute(router, licenseState, encryptedSavedObjects);

View file

@ -30,6 +30,7 @@ const createRulesClientMock = () => {
listAlertTypes: jest.fn(),
getAlertSummary: jest.fn(),
getExecutionLogForRule: jest.fn(),
getGlobalExecutionLogWithAuth: jest.fn(),
getActionErrorLog: jest.fn(),
getSpaceId: jest.fn(),
bulkEdit: jest.fn(),

View file

@ -25,6 +25,7 @@ export enum RuleAuditAction {
AGGREGATE = 'rule_aggregate',
BULK_EDIT = 'rule_bulk_edit',
GET_EXECUTION_LOG = 'rule_get_execution_log',
GET_GLOBAL_EXECUTION_LOG = 'rule_get_global_execution_log',
GET_ACTION_ERROR_LOG = 'rule_get_action_error_log',
SNOOZE = 'rule_snooze',
UNSNOOZE = 'rule_unsnooze',
@ -53,6 +54,11 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
'accessing execution log for',
'accessed execution log for',
],
rule_get_global_execution_log: [
'access execution log',
'accessing execution log',
'accessed execution log',
],
rule_get_action_error_log: [
'access action error log for',
'accessing action error log for',
@ -79,6 +85,7 @@ const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_alert_unmute: 'change',
rule_aggregate: 'access',
rule_get_execution_log: 'access',
rule_get_global_execution_log: 'access',
rule_get_action_error_log: 'access',
rule_snooze: 'change',
rule_unsnooze: 'change',

View file

@ -365,6 +365,15 @@ export interface GetExecutionLogByIdParams {
sort: estypes.Sort;
}
export interface GetGlobalExecutionLogParams {
dateStart: string;
dateEnd?: string;
filter?: string;
page: number;
perPage: number;
sort: estypes.Sort;
}
export interface GetActionErrorLogByIdParams {
id: string;
dateStart: string;
@ -875,6 +884,100 @@ export class RulesClient {
}
}
public async getGlobalExecutionLogWithAuth({
dateStart,
dateEnd,
filter,
page,
perPage,
sort,
}: GetGlobalExecutionLogParams): Promise<IExecutionLogResult> {
this.logger.debug(`getGlobalExecutionLogWithAuth(): getting global execution log`);
let authorizationTuple;
try {
authorizationTuple = await this.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Alert,
{
type: AlertingAuthorizationFilterType.KQL,
fieldNames: {
ruleTypeId: 'kibana.alert.rule.rule_type_id',
consumer: 'kibana.alert.rule.consumer',
},
}
);
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.GET_GLOBAL_EXECUTION_LOG,
error,
})
);
throw error;
}
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.GET_GLOBAL_EXECUTION_LOG,
})
);
const dateNow = new Date();
const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow);
const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow);
const eventLogClient = await this.getEventLogClient();
try {
const aggResult = await eventLogClient.aggregateEventsWithAuthFilter(
'alert',
authorizationTuple.filter as KueryNode,
{
start: parsedDateStart.toISOString(),
end: parsedDateEnd.toISOString(),
aggs: getExecutionLogAggregation({
filter,
page,
perPage,
sort,
}),
}
);
const formattedResult = formatExecutionLogResult(aggResult);
const ruleIds = [...new Set(formattedResult.data.map((l) => l.rule_id))].filter(
Boolean
) as string[];
const ruleNameIdEntries = await Promise.all(
ruleIds.map(async (id) => {
try {
const result = await this.get({ id });
return [id, result.name];
} catch (e) {
return [id, id];
}
})
);
const ruleNameIdMap: Record<string, string> = ruleNameIdEntries.reduce(
(result, [key, val]) => ({ ...result, [key]: val }),
{}
);
return {
...formattedResult,
data: formattedResult.data.map((entry) => ({
...entry,
rule_name: ruleNameIdMap[entry.rule_id!],
})),
};
} catch (err) {
this.logger.debug(
`rulesClient.getGlobalExecutionLogWithAuth(): error searching global event log: ${err.message}`
);
throw err;
}
}
public async getActionErrorLog({
id,
dateStart,

View file

@ -21,6 +21,7 @@ import { RawRule } from '../../types';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib';
import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation';
import { fromKueryExpression } from '@kbn/es-query';
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -142,6 +143,9 @@ const aggregateResults = {
_id: 'S4wIZX8B8TGQpG7XQZns',
_score: 1.0,
_source: {
rule: {
id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
event: {
outcome: 'success',
},
@ -171,6 +175,25 @@ const aggregateResults = {
value: 1.646667512617e12,
value_as_string: '2022-03-07T15:38:32.617Z',
},
ruleId: {
hits: {
total: {
value: 1,
relation: 'eq',
},
max_score: 1.0,
hits: [
{
_index: '.kibana-event-log-8.2.0-000001',
_id: 'S4wIZX8B8TGQpG7XQZns',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
},
},
],
},
},
},
actionExecution: {
meta: {},
@ -225,6 +248,7 @@ const aggregateResults = {
_id: 'a4wIZX8B8TGQpG7Xwpnz',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
event: {
outcome: 'success',
},
@ -254,6 +278,25 @@ const aggregateResults = {
value: 1.646667545604e12,
value_as_string: '2022-03-07T15:39:05.604Z',
},
ruleId: {
hits: {
total: {
value: 1,
relation: 'eq',
},
max_score: 1.0,
hits: [
{
_index: '.kibana-event-log-8.2.0-000001',
_id: 'S4wIZX8B8TGQpG7XQZns',
_score: 1.0,
_source: {
rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef' },
},
},
],
},
},
},
actionExecution: {
meta: {},
@ -333,6 +376,7 @@ describe('getExecutionLogForRule()', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -353,6 +397,7 @@ describe('getExecutionLogForRule()', () => {
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3345,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
},
],
});
@ -623,3 +668,72 @@ describe('getExecutionLogForRule()', () => {
});
});
});
describe('getGlobalExecutionLogWithAuth()', () => {
let rulesClient: RulesClient;
beforeEach(() => {
rulesClient = new RulesClient(rulesClientParams);
});
test('runs as expected with some event log aggregation data', async () => {
const ruleSO = getRuleSavedObject({});
authorization.getFindAuthorizationFilter.mockResolvedValue({
filter: fromKueryExpression('*'),
ensureRuleTypeIsAuthorized() {},
});
unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO);
eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValueOnce(aggregateResults);
const result = await rulesClient.getGlobalExecutionLogWithAuth(getExecutionLogByIdParams());
expect(result).toEqual({
total: 374,
data: [
{
id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9',
timestamp: '2022-03-07T15:38:32.617Z',
duration_ms: 1056,
status: 'success',
message:
"rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'",
version: '8.2.0',
num_active_alerts: 5,
num_new_alerts: 5,
num_recovered_alerts: 0,
num_triggered_actions: 5,
num_generated_actions: 5,
num_succeeded_actions: 5,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
timestamp: '2022-03-07T15:39:05.604Z',
duration_ms: 1165,
status: 'success',
message:
"rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'",
version: '8.2.0',
num_active_alerts: 5,
num_new_alerts: 5,
num_recovered_alerts: 5,
num_triggered_actions: 5,
num_generated_actions: 5,
num_succeeded_actions: 5,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
timed_out: false,
schedule_delay_ms: 3345,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
},
],
});
});
});

View file

@ -25,6 +25,7 @@ const createClusterClientMock = () => {
setIndexAliasToHidden: jest.fn(),
queryEventsBySavedObjects: jest.fn(),
aggregateEventsBySavedObjects: jest.fn(),
aggregateEventsWithAuthFilter: jest.fn(),
shutdown: jest.fn(),
};
return mock;

View file

@ -13,11 +13,14 @@ import {
getQueryBody,
FindEventsOptionsBySavedObjectFilter,
AggregateEventsOptionsBySavedObjectFilter,
AggregateEventsWithAuthFilter,
getQueryBodyWithAuthFilter,
} from './cluster_client_adapter';
import { AggregateOptionsType, queryOptionsSchema } from '../event_log_client';
import { delay } from '../lib/delay';
import { pick, times } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/types';
import { fromKueryExpression } from '@kbn/es-query';
type MockedLogger = ReturnType<typeof loggingSystemMock['createLogger']>;
@ -728,6 +731,104 @@ describe('aggregateEventsBySavedObject', () => {
});
});
describe('aggregateEventsWithAuthFilter', () => {
const DEFAULT_OPTIONS = {
...queryOptionsSchema.validate({}),
aggs: {
genericAgg: {
term: {
field: 'event.action',
size: 10,
},
},
},
};
test('should call cluster with correct options', async () => {
clusterClient.search.mockResponse({
aggregations: {
genericAgg: {
buckets: [
{
key: 'execute',
doc_count: 10,
},
{
key: 'execute-start',
doc_count: 10,
},
{
key: 'new-instance',
doc_count: 2,
},
],
},
},
hits: {
hits: [],
total: { relation: 'eq', value: 0 },
},
took: 0,
timed_out: false,
_shards: {
failed: 0,
successful: 0,
total: 0,
skipped: 0,
},
});
const options: AggregateEventsWithAuthFilter = {
index: 'index-name',
namespace: 'namespace',
type: 'saved-object-type',
aggregateOptions: DEFAULT_OPTIONS as AggregateOptionsType,
authFilter: fromKueryExpression('test:test'),
};
const result = await clusterClientAdapter.aggregateEventsWithAuthFilter(options);
const [query] = clusterClient.search.mock.calls[0];
expect(query).toEqual({
index: 'index-name',
body: {
size: 0,
query: getQueryBodyWithAuthFilter(
logger,
options,
pick(options.aggregateOptions, ['start', 'end', 'filter'])
),
aggs: {
genericAgg: {
term: {
field: 'event.action',
size: 10,
},
},
},
},
});
expect(result).toEqual({
aggregations: {
genericAgg: {
buckets: [
{
key: 'execute',
doc_count: 10,
},
{
key: 'execute-start',
doc_count: 10,
},
{
key: 'new-instance',
doc_count: 2,
},
],
},
},
});
});
});
describe('getQueryBody', () => {
const options = {
index: 'index-name',
@ -1411,6 +1512,431 @@ describe('getQueryBody', () => {
});
});
describe('getQueryBodyWithAuthFilter', () => {
const options = {
index: 'index-name',
namespace: undefined,
type: 'saved-object-type',
authFilter: fromKueryExpression('test:test'),
};
test('should correctly build query with namespace filter when namespace is undefined', () => {
expect(
getQueryBodyWithAuthFilter(logger, options as AggregateEventsWithAuthFilter, {})
).toEqual({
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
],
},
},
},
},
],
},
});
});
test('should correctly build query with namespace filter when namespace is specified', () => {
expect(
getQueryBodyWithAuthFilter(
logger,
{ ...options, namespace: 'namespace' } as AggregateEventsWithAuthFilter,
{}
)
).toEqual({
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
term: {
'kibana.saved_objects.namespace': {
value: 'namespace',
},
},
},
],
},
},
},
},
],
},
});
});
test('should correctly build query when filter is specified', () => {
expect(
getQueryBodyWithAuthFilter(logger, options as AggregateEventsWithAuthFilter, {
filter: 'event.provider: alerting AND event.action:execute',
})
).toEqual({
bool: {
filter: {
bool: {
filter: [
{
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'event.provider': 'alerting',
},
},
],
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
],
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
],
},
},
},
},
],
},
});
});
test('should correctly build query when start is specified', () => {
expect(
getQueryBodyWithAuthFilter(logger, options as AggregateEventsWithAuthFilter, {
start: '2020-07-08T00:52:28.350Z',
})
).toEqual({
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
],
},
},
},
},
{
range: {
'@timestamp': {
gte: '2020-07-08T00:52:28.350Z',
},
},
},
],
},
});
});
test('should correctly build query when end is specified', () => {
expect(
getQueryBodyWithAuthFilter(logger, options as AggregateEventsWithAuthFilter, {
end: '2020-07-10T00:52:28.350Z',
})
).toEqual({
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
],
},
},
},
},
{
range: {
'@timestamp': {
lte: '2020-07-10T00:52:28.350Z',
},
},
},
],
},
});
});
test('should correctly build query when start and end are specified', () => {
expect(
getQueryBodyWithAuthFilter(logger, options as AggregateEventsWithAuthFilter, {
start: '2020-07-08T00:52:28.350Z',
end: '2020-07-10T00:52:28.350Z',
})
).toEqual({
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
],
},
},
},
},
{
range: {
'@timestamp': {
gte: '2020-07-08T00:52:28.350Z',
},
},
},
{
range: {
'@timestamp': {
lte: '2020-07-10T00:52:28.350Z',
},
},
},
],
},
});
});
});
type RetryableFunction = () => boolean;
const RETRY_UNTIL_DEFAULT_COUNT = 20;

View file

@ -12,7 +12,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
import { Logger, ElasticsearchClient } from '@kbn/core/server';
import util from 'util';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { fromKueryExpression, toElasticsearchQuery, KueryNode, nodeBuilder } from '@kbn/es-query';
import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types';
import { AggregateOptionsType, FindOptionsType, QueryOptionsType } from '../event_log_client';
import { ParsedIndexAlias } from './init';
@ -50,6 +50,14 @@ interface QueryOptionsEventsBySavedObjectFilter {
legacyIds?: string[];
}
export interface AggregateEventsWithAuthFilter {
index: string;
namespace: string | undefined;
type: string;
authFilter: KueryNode;
aggregateOptions: AggregateOptionsType;
}
export type FindEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & {
findOptions: FindOptionsType;
};
@ -415,6 +423,126 @@ export class ClusterClientAdapter<TDoc extends { body: AliasAny; index: string }
);
}
}
public async aggregateEventsWithAuthFilter(
queryOptions: AggregateEventsWithAuthFilter
): Promise<AggregateEventsBySavedObjectResult> {
const { index, type, aggregateOptions } = queryOptions;
const { aggs } = aggregateOptions;
const esClient = await this.elasticsearchClientPromise;
const query = getQueryBodyWithAuthFilter(
this.logger,
queryOptions,
pick(queryOptions.aggregateOptions, ['start', 'end', 'filter'])
);
const body: estypes.SearchRequest['body'] = {
size: 0,
query,
aggs,
};
try {
const { aggregations } = await esClient.search<IValidatedEvent>({
index,
body,
});
return {
aggregations,
};
} catch (err) {
throw new Error(
`querying for Event Log by for type "${type}" and auth filter failed with: ${err.message}`
);
}
}
}
export function getQueryBodyWithAuthFilter(
logger: Logger,
opts: AggregateEventsWithAuthFilter,
queryOptions: QueryOptionsType
) {
const { namespace, type, authFilter } = opts;
const { start, end, filter } = queryOptions ?? {};
const namespaceQuery = getNamespaceQuery(namespace);
let dslFilterQuery: estypes.QueryDslBoolQuery['filter'];
try {
const filterKueryNode = filter ? fromKueryExpression(filter) : null;
const queryFilter = filterKueryNode
? nodeBuilder.and([filterKueryNode, authFilter as KueryNode])
: authFilter;
dslFilterQuery = queryFilter ? toElasticsearchQuery(queryFilter) : undefined;
} catch (err) {
logger.debug(
`esContext: Invalid kuery syntax for the filter (${filter}) error: ${JSON.stringify({
message: err.message,
statusCode: err.statusCode,
})}`
);
throw err;
}
const savedObjectsQueryMust: estypes.QueryDslQueryContainer[] = [
{
term: {
'kibana.saved_objects.rel': {
value: SAVED_OBJECT_REL_PRIMARY,
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: type,
},
},
},
// @ts-expect-error undefined is not assignable as QueryDslTermQuery value
namespaceQuery,
];
const musts: estypes.QueryDslQueryContainer[] = [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: reject(savedObjectsQueryMust, isUndefined),
},
},
},
},
];
if (start) {
musts.push({
range: {
'@timestamp': {
gte: start,
},
},
});
}
if (end) {
musts.push({
range: {
'@timestamp': {
lte: end,
},
},
});
}
return {
bool: {
...(dslFilterQuery ? { filter: dslFilterQuery } : {}),
must: reject(musts, isUndefined),
},
};
}
function getNamespaceQuery(namespace?: string) {
@ -446,9 +574,15 @@ export function getQueryBody(
const { start, end, filter } = queryOptions ?? {};
const namespaceQuery = getNamespaceQuery(namespace);
let filterKueryNode;
try {
filterKueryNode = JSON.parse(filter ?? '');
} catch (e) {
filterKueryNode = filter ? fromKueryExpression(filter) : null;
}
let dslFilterQuery: estypes.QueryDslBoolQuery['filter'];
try {
dslFilterQuery = filter ? toElasticsearchQuery(fromKueryExpression(filter)) : undefined;
dslFilterQuery = filterKueryNode ? toElasticsearchQuery(filterKueryNode) : undefined;
} catch (err) {
logger.debug(
`esContext: Invalid kuery syntax for the filter (${filter}) error: ${JSON.stringify({
@ -492,6 +626,7 @@ export function getQueryBody(
];
const shouldQuery = [];
shouldQuery.push({
bool: {
must: [

View file

@ -11,6 +11,7 @@ const createEventLogClientMock = () => {
const mock: jest.Mocked<IEventLogClient> = {
findEventsBySavedObjectIds: jest.fn(),
aggregateEventsBySavedObjectIds: jest.fn(),
aggregateEventsWithAuthFilter: jest.fn(),
};
return mock;
};

View file

@ -12,6 +12,7 @@ import { contextMock } from './es/context.mock';
import { merge } from 'lodash';
import moment from 'moment';
import { IClusterClientAdapter } from './es/cluster_client_adapter';
import { fromKueryExpression } from '@kbn/es-query';
const expectedSavedObject = {
id: 'saved-object-id',
@ -240,6 +241,38 @@ describe('EventLogStart', () => {
});
});
});
describe('aggregateEventsWithAuthFilter', () => {
const testAuthFilter = fromKueryExpression('test:test');
test('throws when no aggregation is defined in options', async () => {
savedObjectGetter.mockResolvedValueOnce(expectedSavedObject);
await expect(
eventLogClient.aggregateEventsWithAuthFilter('saved-object-type', testAuthFilter)
).rejects.toMatchInlineSnapshot(`[Error: No aggregation defined!]`);
});
test('calls aggregateEventsWithAuthFilter with given aggregation', async () => {
savedObjectGetter.mockResolvedValueOnce(expectedSavedObject);
await eventLogClient.aggregateEventsWithAuthFilter('saved-object-type', testAuthFilter, {
aggs: { myAgg: {} },
});
expect(esContext.esAdapter.aggregateEventsWithAuthFilter).toHaveBeenCalledWith({
index: esContext.esNames.indexPattern,
namespace: undefined,
type: 'saved-object-type',
authFilter: testAuthFilter,
aggregateOptions: {
aggs: { myAgg: {} },
page: 1,
per_page: 10,
sort: [
{
sort_field: '@timestamp',
sort_order: 'asc',
},
],
},
});
});
});
});
function fakeEvent(overrides = {}) {

View file

@ -12,6 +12,7 @@ import { IClusterClient, KibanaRequest } from '@kbn/core/server';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SpacesServiceStart } from '@kbn/spaces-plugin/server';
import { KueryNode } from '@kbn/core-saved-objects-api-server';
import { EsContext } from './es';
import { IEventLogClient } from './types';
import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter';
@ -138,6 +139,32 @@ export class EventLogClient implements IEventLogClient {
});
}
public async aggregateEventsWithAuthFilter(
type: string,
authFilter: KueryNode,
options?: AggregateOptionsType
) {
if (!authFilter) {
throw new Error('No authorization filter defined!');
}
const aggs = options?.aggs;
if (!aggs) {
throw new Error('No aggregation defined!');
}
// validate other query options separately from
const aggregateOptions = queryOptionsSchema.validate(omit(options, 'aggs') ?? {});
return await this.esContext.esAdapter.aggregateEventsWithAuthFilter({
index: this.esContext.esNames.indexPattern,
namespace: await this.getNamespace(),
type,
authFilter,
aggregateOptions: { ...aggregateOptions, aggs } as AggregateOptionsType,
});
}
private async getNamespace() {
const space = await this.spacesService?.getActiveSpace(this.request);
return space && this.spacesService?.spaceIdToNamespace(space.id);

View file

@ -7,6 +7,7 @@
import { schema, TypeOf } from '@kbn/config-schema';
import type { IRouter, KibanaRequest, CustomRequestHandlerContext } from '@kbn/core/server';
import { KueryNode } from '@kbn/es-query';
export type { IEvent, IValidatedEvent } from '../generated/schemas';
export { EventSchema, ECS_VERSION } from '../generated/schemas';
@ -62,6 +63,11 @@ export interface IEventLogClient {
options?: Partial<AggregateOptionsType>,
legacyIds?: string[]
): Promise<AggregateEventsBySavedObjectResult>;
aggregateEventsWithAuthFilter(
type: string,
authFilter: KueryNode,
options?: Partial<AggregateOptionsType>
): Promise<AggregateEventsBySavedObjectResult>;
}
export interface IEventLogger {

View file

@ -185,7 +185,7 @@ export function RuleDetailsPage() {
}),
'data-test-subj': 'eventLogListTab',
content: getRuleEventLogList<'default'>({
rule,
ruleId: rule?.id,
ruleType,
} as RuleEventLogListProps),
},

View file

@ -73,7 +73,7 @@ export const renderApp = (deps: TriggersAndActionsUiServices) => {
export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => {
const { dataViews, uiSettings, theme$ } = deps;
const sections: Section[] = ['rules', 'connectors', 'alerts'];
const sections: Section[] = ['rules', 'connectors', 'logs', 'alerts'];
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
const sectionsRegex = sections.join('|');

View file

@ -13,11 +13,12 @@ export {
} from '@kbn/alerting-plugin/common';
export { BASE_ACTION_API_PATH, INTERNAL_BASE_ACTION_API_PATH } from '@kbn/actions-plugin/common';
export type Section = 'connectors' | 'rules' | 'alerts';
export type Section = 'connectors' | 'rules' | 'alerts' | 'logs';
export const routeToHome = `/`;
export const routeToConnectors = `/connectors`;
export const routeToRules = `/rules`;
export const routeToLogs = `/logs`;
export const routeToRuleDetails = `/rule/:ruleId`;
export const routeToInternalAlerts = `/alerts`;
export const legacyRouteToRules = `/alerts`;
@ -41,6 +42,8 @@ export const DEFAULT_SEARCH_PAGE_SIZE: number = 10;
export const DEFAULT_RULE_INTERVAL = '1m';
export const RULE_EXECUTION_LOG_COLUMN_IDS = [
'rule_id',
'rule_name',
'id',
'timestamp',
'execution_duration',
@ -73,6 +76,7 @@ export const RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS = [
];
export const LOCKED_COLUMNS = [
'rule_name',
'timestamp',
'execution_duration',
'status',
@ -81,4 +85,5 @@ export const LOCKED_COLUMNS = [
'num_errored_actions',
];
export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [...LOCKED_COLUMNS];
export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [...LOCKED_COLUMNS.slice(1)];
export const GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = ['rule_name', ...LOCKED_COLUMNS];

View file

@ -70,7 +70,7 @@ describe('home', () => {
let home = mountWithIntl(<TriggersActionsUIHome {...props} />);
// Just rules/connectors
expect(home.find('.euiTab__content').length).toBe(2);
expect(home.find('.euiTab__content').length).toBe(3);
(getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
if (feature === 'internalAlertsTable') {
@ -81,6 +81,6 @@ describe('home', () => {
home = mountWithIntl(<TriggersActionsUIHome {...props} />);
// alerts now too!
expect(home.find('.euiTab__content').length).toBe(3);
expect(home.find('.euiTab__content').length).toBe(4);
});
});

View file

@ -11,7 +11,13 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiButtonEmpty, EuiPageHeader } from '@elastic/eui';
import { getIsExperimentalFeatureEnabled } from '../common/get_experimental_features';
import { Section, routeToConnectors, routeToRules, routeToInternalAlerts } from './constants';
import {
Section,
routeToConnectors,
routeToRules,
routeToInternalAlerts,
routeToLogs,
} from './constants';
import { getAlertingSectionBreadcrumb } from './lib/breadcrumb';
import { getCurrentDocTitle } from './lib/doc_title';
import { hasShowActionsCapability } from './lib/capabilities';
@ -25,6 +31,7 @@ const ActionsConnectorsList = lazy(
() => import('./sections/actions_connectors_list/components/actions_connectors_list')
);
const RulesList = lazy(() => import('./sections/rules_list/components/rules_list'));
const LogsList = lazy(() => import('./sections/logs_list/components/logs_list'));
const AlertsPage = lazy(() => import('./sections/alerts_table/alerts_page'));
export interface MatchParams {
@ -71,6 +78,11 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
});
}
tabs.push({
id: 'logs',
name: <FormattedMessage id="xpack.triggersActionsUI.home.logsTabTitle" defaultMessage="Logs" />,
});
if (isInternalAlertsTableEnabled) {
tabs.push({
id: 'alerts',
@ -138,6 +150,11 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
<HealthContextProvider>
<HealthCheck waitForCheck={true}>
<Switch>
<Route
exact
path={routeToLogs}
component={suspendedComponentWithProps(LogsList, 'xl')}
/>
{canShowActions && (
<Route
exact

View file

@ -20,8 +20,14 @@ export { loadRuleTypes } from './rule_types';
export type { LoadRulesProps } from './rules_helpers';
export { loadRules } from './rules';
export { loadRuleState } from './state';
export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations';
export { loadExecutionLogAggregations } from './load_execution_log_aggregations';
export type {
LoadExecutionLogAggregationsProps,
LoadGlobalExecutionLogAggregationsProps,
} from './load_execution_log_aggregations';
export {
loadExecutionLogAggregations,
loadGlobalExecutionLogAggregations,
} from './load_execution_log_aggregations';
export type { LoadActionErrorLogProps } from './load_action_error_log';
export { loadActionErrorLog } from './load_action_error_log';
export { unmuteAlertInstance } from './unmute_alert';

View file

@ -74,6 +74,8 @@ export interface LoadExecutionLogAggregationsProps {
sort?: SortField[];
}
export type LoadGlobalExecutionLogAggregationsProps = Omit<LoadExecutionLogAggregationsProps, 'id'>;
export const loadExecutionLogAggregations = async ({
id,
http,
@ -106,3 +108,35 @@ export const loadExecutionLogAggregations = async ({
return rewriteBodyRes(result);
};
export const loadGlobalExecutionLogAggregations = async ({
http,
dateStart,
dateEnd,
outcomeFilter,
message,
perPage = 10,
page = 0,
sort = [],
}: LoadGlobalExecutionLogAggregationsProps & { http: HttpSetup }) => {
const sortField: any[] = sort;
const filter = getFilter({ outcomeFilter, message });
const result = await http.get<AsApiContract<IExecutionLogResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/_global_execution_logs`,
{
query: {
date_start: dateStart,
date_end: dateEnd,
filter: filter.length ? filter.join(' and ') : undefined,
per_page: perPage,
// Need to add the + 1 for pages because APIs are 1 indexed,
// whereas data grid sorts are 0 indexed.
page: page + 1,
sort: sortField.length ? JSON.stringify(sortField) : undefined,
},
}
);
return rewriteBodyRes(result);
};

View file

@ -36,7 +36,9 @@ import {
alertingFrameworkHealth,
resolveRule,
loadExecutionLogAggregations,
loadGlobalExecutionLogAggregations,
LoadExecutionLogAggregationsProps,
LoadGlobalExecutionLogAggregationsProps,
loadActionErrorLog,
LoadActionErrorLogProps,
snoozeRule,
@ -70,6 +72,9 @@ export interface ComponentOpts {
loadExecutionLogAggregations: (
props: LoadExecutionLogAggregationsProps
) => Promise<IExecutionLogResult>;
loadGlobalExecutionLogAggregations: (
props: LoadGlobalExecutionLogAggregationsProps
) => Promise<IExecutionLogResult>;
loadActionErrorLog: (props: LoadActionErrorLogProps) => Promise<IExecutionErrorsResult>;
getHealth: () => Promise<AlertingFrameworkHealth>;
resolveRule: (id: Rule['id']) => Promise<ResolvedRule>;
@ -151,6 +156,14 @@ export function withBulkRuleOperations<T>(
http,
})
}
loadGlobalExecutionLogAggregations={async (
loadProps: LoadGlobalExecutionLogAggregationsProps
) =>
loadGlobalExecutionLogAggregations({
...loadProps,
http,
})
}
loadActionErrorLog={async (loadProps: LoadActionErrorLogProps) =>
loadActionErrorLog({
...loadProps,

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
import { RuleEventLogListTableWithApi } from '../../rule_details/components/rule_event_log_list_table';
const GLOBAL_EVENT_LOG_LIST_STORAGE_KEY =
'xpack.triggersActionsUI.globalEventLogList.initialColumns';
export const LogsList = () => {
return suspendedComponentWithProps(
RuleEventLogListTableWithApi,
'xl'
)({
ruleId: '*',
refreshToken: 0,
initialPageSize: 50,
hasRuleNames: true,
localStorageKey: GLOBAL_EVENT_LOG_LIST_STORAGE_KEY,
});
};
// eslint-disable-next-line import/no-default-export
export default LogsList;

View file

@ -31,7 +31,7 @@ import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experime
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
import RuleStatusPanelWithApi from './rule_status_panel';
const RuleEventLogListWithApi = lazy(() => import('./rule_event_log_list'));
const RuleEventLogList = lazy(() => import('./rule_event_log_list'));
const RuleAlertList = lazy(() => import('./rule_alert_list'));
const RuleDefinition = lazy(() => import('./rule_definition'));
@ -104,11 +104,11 @@ export function RuleComponent({
}),
'data-test-subj': 'eventLogListTab',
content: suspendedComponentWithProps<RuleEventLogListProps<'stackManagement'>>(
RuleEventLogListWithApi,
RuleEventLogList,
'xl'
)({
fetchRuleSummary: false,
rule,
ruleId: rule.id,
ruleType,
ruleSummary,
numberOfExecutions,

View file

@ -11,7 +11,6 @@ import { act } from 'react-dom/test-utils';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { RuleActionErrorLogFlyout } from './rule_action_error_log_flyout';
import { loadActionErrorLog } from '../../../lib/rule_api/load_action_error_log';
import { Rule } from '../../../../types';
jest.mock('../../../lib/rule_api/load_action_error_log', () => ({
loadActionErrorLog: jest.fn(),
@ -43,33 +42,9 @@ const mockErrorLogResponse = {
],
};
const mockRule: Rule = {
id: uuid.v4(),
enabled: true,
name: `rule-${uuid.v4()}`,
tags: [],
ruleTypeId: '.noop',
consumer: 'consumer',
schedule: { interval: '1m' },
actions: [],
params: {},
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
};
const mockExecution: any = {
id: uuid.v4(),
rule_id: uuid.v4(),
timestamp: '2022-03-20T07:40:44-07:00',
duration: 5000000,
status: 'success',
@ -98,7 +73,7 @@ describe('rule_action_error_log_flyout', () => {
it('renders correctly', async () => {
const wrapper = mountWithIntl(
<RuleActionErrorLogFlyout rule={mockRule} runLog={mockExecution} onClose={mockClose} />
<RuleActionErrorLogFlyout runLog={mockExecution} onClose={mockClose} />
);
await act(async () => {
@ -115,7 +90,7 @@ describe('rule_action_error_log_flyout', () => {
it('can close the flyout', async () => {
const wrapper = mountWithIntl(
<RuleActionErrorLogFlyout rule={mockRule} runLog={mockExecution} onClose={mockClose} />
<RuleActionErrorLogFlyout runLog={mockExecution} onClose={mockClose} />
);
await act(async () => {
@ -130,7 +105,7 @@ describe('rule_action_error_log_flyout', () => {
it('switches between push and overlay flyout depending on the size of the screen', async () => {
const wrapper = mountWithIntl(
<RuleActionErrorLogFlyout rule={mockRule} runLog={mockExecution} onClose={mockClose} />
<RuleActionErrorLogFlyout runLog={mockExecution} onClose={mockClose} />
);
await act(async () => {

View file

@ -21,23 +21,21 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { IExecutionLog } from '@kbn/alerting-plugin/common';
import { Rule } from '../../../../types';
import { RuleErrorLogWithApi } from './rule_error_log';
import { RuleActionErrorBadge } from './rule_action_error_badge';
export interface RuleActionErrorLogFlyoutProps {
rule: Rule;
runLog: IExecutionLog;
refreshToken?: number;
onClose: () => void;
}
export const RuleActionErrorLogFlyout = (props: RuleActionErrorLogFlyoutProps) => {
const { rule, runLog, refreshToken, onClose } = props;
const { runLog, refreshToken, onClose } = props;
const { euiTheme } = useEuiTheme();
const { id, message, num_errored_actions: totalErrors } = runLog;
const { id, rule_id: ruleId, message, num_errored_actions: totalErrors } = runLog;
const isFlyoutPush = useIsWithinBreakpoints(['xl']);
@ -84,7 +82,7 @@ export const RuleActionErrorLogFlyout = (props: RuleActionErrorLogFlyoutProps) =
}}
/>
</div>
<RuleErrorLogWithApi rule={rule} runId={id} refreshToken={refreshToken} />
<RuleErrorLogWithApi ruleId={ruleId} runId={id} refreshToken={refreshToken} />
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -141,7 +141,7 @@ describe('rule_error_log', () => {
it('renders correctly', async () => {
const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0);
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
// No data initially
@ -184,7 +184,7 @@ describe('rule_error_log', () => {
const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0);
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
await act(async () => {
@ -233,7 +233,7 @@ describe('rule_error_log', () => {
});
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
await act(async () => {
@ -280,7 +280,7 @@ describe('rule_error_log', () => {
const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0);
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
await act(async () => {
@ -328,7 +328,7 @@ describe('rule_error_log', () => {
it('does not show the refine search prompt normally', async () => {
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
await act(async () => {
@ -346,7 +346,7 @@ describe('rule_error_log', () => {
});
const wrapper = mountWithIntl(
<RuleErrorLog rule={mockRule} loadActionErrorLog={loadActionErrorLogMock} />
<RuleErrorLog ruleId={mockRule.id} loadActionErrorLog={loadActionErrorLogMock} />
);
await act(async () => {

View file

@ -25,7 +25,6 @@ import { IExecutionErrors } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { Rule } from '../../../../types';
import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
@ -61,14 +60,14 @@ const updateButtonProps = {
const MAX_RESULTS = 1000;
export type RuleErrorLogProps = {
rule: Rule;
ruleId: string;
runId?: string;
refreshToken?: number;
requestRefresh?: () => Promise<void>;
} & Pick<RuleApis, 'loadActionErrorLog'>;
export const RuleErrorLog = (props: RuleErrorLogProps) => {
const { rule, runId, loadActionErrorLog, refreshToken } = props;
const { ruleId, runId, loadActionErrorLog, refreshToken } = props;
const { uiSettings, notifications } = useKibana().services;
@ -131,7 +130,7 @@ export const RuleErrorLog = (props: RuleErrorLogProps) => {
setIsLoading(true);
try {
const result = await loadActionErrorLog({
id: rule.id,
id: ruleId,
runId,
message: searchText,
dateStart: getParsedDate(dateStart),

View file

@ -59,6 +59,7 @@ export interface RuleEventLogDataGrid {
dateFormat: string;
pageSizeOptions?: number[];
selectedRunLog?: IExecutionLog;
showRuleNameAndIdColumns?: boolean;
onChangeItemsPerPage: (pageSize: number) => void;
onChangePage: (pageIndex: number) => void;
onFilterChange: (filter: string[]) => void;
@ -160,6 +161,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
dateFormat,
visibleColumns,
selectedRunLog,
showRuleNameAndIdColumns = false,
setVisibleColumns,
setSortingColumns,
onChangeItemsPerPage,
@ -180,6 +182,39 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
const columns: EuiDataGridColumn[] = useMemo(
() => [
...(showRuleNameAndIdColumns
? [
{
id: 'rule_id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId',
{
defaultMessage: 'Rule Id',
}
),
isSortable: getIsColumnSortable('rule_id'),
actions: {
showSortAsc: false,
showSortDesc: false,
},
},
{
id: 'rule_name',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName',
{
defaultMessage: 'Rule',
}
),
isSortable: getIsColumnSortable('rule_name'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
{
id: 'id',
displayAsText: i18n.translate(
@ -394,7 +429,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
isSortable: getIsColumnSortable('timed_out'),
},
],
[getPaginatedRowIndex, onFlyoutOpen, onFilterChange, logs]
[getPaginatedRowIndex, onFlyoutOpen, onFilterChange, showRuleNameAndIdColumns, logs]
);
const columnVisibilityProps = useMemo(
@ -524,6 +559,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
const value = logs[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string;
const actionErrors = logs[pagedRowIndex]?.num_errored_actions || (0 as number);
const version = logs?.[pagedRowIndex]?.version;
const ruleId = runLog?.rule_id;
if (columnId === 'num_errored_actions' && runLog) {
return (
@ -555,6 +591,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
value={value}
version={version}
dateFormat={dateFormat}
ruleId={ruleId}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -15,7 +15,10 @@ import { EuiSuperDatePicker, EuiDataGrid } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogList } from './rule_event_log_list';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS } from '../../../constants';
import {
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
} from '../../../constants';
import { mockRule, mockRuleType, mockRuleSummary } from './test_helpers';
import { RuleType } from '../../../../types';
import { loadActionErrorLog } from '../../../lib/rule_api/load_action_error_log';
@ -161,7 +164,7 @@ describe.skip('rule_event_log_list', () => {
it('renders correctly', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -207,7 +210,7 @@ describe.skip('rule_event_log_list', () => {
it('can sort by single and/or multiple column(s)', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -312,7 +315,7 @@ describe.skip('rule_event_log_list', () => {
it('can filter by execution log outcome status', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -375,7 +378,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -435,7 +438,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -492,7 +495,7 @@ describe.skip('rule_event_log_list', () => {
it('can save display columns to localStorage', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -510,7 +513,7 @@ describe.skip('rule_event_log_list', () => {
JSON.parse(
localStorage.getItem('xpack.triggersActionsUI.ruleEventLogList.initialColumns') ?? 'null'
)
).toEqual(RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS);
).toEqual(GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS);
wrapper.find('[data-test-subj="dataGridColumnSelectorButton"] button').simulate('click');
@ -535,7 +538,7 @@ describe.skip('rule_event_log_list', () => {
it('does not show the refine search prompt normally', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -560,7 +563,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -610,7 +613,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -637,7 +640,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -664,7 +667,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -732,7 +735,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -765,7 +768,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
fetchRuleSummary={false}
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary({ ruleTypeId: ruleMock.ruleTypeId })}
numberOfExecutions={60}
@ -804,7 +807,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
fetchRuleSummary={false}
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleTypeCustom}
ruleSummary={ruleSummary}
numberOfExecutions={60}
@ -842,7 +845,7 @@ describe.skip('rule_event_log_list', () => {
const wrapper = mountWithIntl(
<RuleEventLogList
fetchRuleSummary={false}
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleTypeCustom}
ruleSummary={ruleSummary}
numberOfExecutions={60}

View file

@ -5,84 +5,29 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import {
EuiFieldSearch,
EuiFlexItem,
EuiFlexGroup,
EuiProgress,
EuiSpacer,
EuiDataGridSorting,
Pagination,
EuiSuperDatePicker,
OnTimeChangeProps,
} from '@elastic/eui';
import { IExecutionLog } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS, LOCKED_COLUMNS } from '../../../constants';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogDataGrid } from './rule_event_log_data_grid';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import { RuleExecutionSummaryAndChartWithApi } from './rule_execution_summary_and_chart';
import { RuleActionErrorLogFlyout } from './rule_action_error_log_flyout';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { LoadExecutionLogAggregationsProps } from '../../../lib/rule_api';
import { Rule, RuleSummary, RuleType } from '../../../../types';
import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
const getParsedDate = (date: string) => {
if (date.includes('now')) {
return datemath.parse(date)?.format() || date;
}
return date;
};
const API_FAILED_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError',
{
defaultMessage: 'Failed to fetch execution history',
}
);
const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.searchPlaceholder',
{
defaultMessage: 'Search event log message',
}
);
import { RuleSummary, RuleType } from '../../../../types';
import { ComponentOpts as RuleApis } from '../../common/components/with_bulk_rule_api_operations';
import { RuleEventLogListTableWithApi } from './rule_event_log_list_table';
const RULE_EVENT_LOG_LIST_STORAGE_KEY = 'xpack.triggersActionsUI.ruleEventLogList.initialColumns';
const getDefaultColumns = (columns: string[]) => {
const columnsWithoutLockedColumn = columns.filter((column) => !LOCKED_COLUMNS.includes(column));
return [...LOCKED_COLUMNS, ...columnsWithoutLockedColumn];
};
const updateButtonProps = {
iconOnly: true,
fill: false,
};
const MAX_RESULTS = 1000;
const ruleEventListContainerStyle = { minHeight: 400 };
export type RuleEventLogListOptions = 'stackManagement' | 'default';
export interface RuleEventLogListCommonProps {
rule: Rule;
ruleId: string;
ruleType: RuleType;
localStorageKey?: string;
refreshToken?: number;
requestRefresh?: () => Promise<void>;
loadExecutionLogAggregations?: RuleApis['loadExecutionLogAggregations'];
fetchRuleSummary?: boolean;
hideChart?: boolean;
}
export interface RuleEventLogListStackManagementProps {
@ -103,7 +48,7 @@ export const RuleEventLogList = <T extends RuleEventLogListOptions>(
props: RuleEventLogListProps<T>
) => {
const {
rule,
ruleId,
ruleType,
localStorageKey = RULE_EVENT_LOG_LIST_STORAGE_KEY,
refreshToken,
@ -118,228 +63,11 @@ export const RuleEventLogList = <T extends RuleEventLogListOptions>(
onChangeDuration,
isLoadingRuleSummary = false,
} = props as RuleEventLogListStackManagementProps;
const { uiSettings, notifications } = useKibana().services;
const [searchText, setSearchText] = useState<string>('');
const [search, setSearch] = useState<string>('');
const [isFlyoutOpen, setIsFlyoutOpen] = useState<boolean>(false);
const [selectedRunLog, setSelectedRunLog] = useState<IExecutionLog | undefined>();
// Data grid states
const [logs, setLogs] = useState<IExecutionLog[]>();
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
return getDefaultColumns(
JSON.parse(localStorage.getItem(localStorageKey) ?? 'null') ||
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS
);
});
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [filter, setFilter] = useState<string[]>([]);
const [actualTotalItemCount, setActualTotalItemCount] = useState<number>(0);
const [pagination, setPagination] = useState<Pagination>({
pageIndex: 0,
pageSize: 10,
totalItemCount: 0,
});
// Date related states
const [isLoading, setIsLoading] = useState<boolean>(false);
const [dateStart, setDateStart] = useState<string>('now-24h');
const [dateEnd, setDateEnd] = useState<string>('now');
const [dateFormat] = useState(() => uiSettings?.get('dateFormat'));
const [commonlyUsedRanges] = useState(() => {
return (
uiSettings
?.get('timepicker:quickRanges')
?.map(({ from, to, display }: { from: string; to: string; display: string }) => ({
start: from,
end: to,
label: display,
})) || []
);
});
const isInitialized = useRef(false);
const isOnLastPage = useMemo(() => {
const { pageIndex, pageSize } = pagination;
return (pageIndex + 1) * pageSize >= MAX_RESULTS;
}, [pagination]);
// Formats the sort columns to be consumed by the API endpoint
const formattedSort = useMemo(() => {
return sortingColumns.map(({ id: sortId, direction }) => ({
[sortId]: {
order: direction,
},
}));
}, [sortingColumns]);
const loadEventLogs = async () => {
if (!loadExecutionLogAggregations) {
return;
}
setIsLoading(true);
try {
const result = await loadExecutionLogAggregations({
id: rule.id,
sort: formattedSort as LoadExecutionLogAggregationsProps['sort'],
outcomeFilter: filter,
message: searchText,
dateStart: getParsedDate(dateStart),
dateEnd: getParsedDate(dateEnd),
page: pagination.pageIndex,
perPage: pagination.pageSize,
});
setLogs(result.data);
setPagination({
...pagination,
totalItemCount: Math.min(result.total, MAX_RESULTS),
});
setActualTotalItemCount(result.total);
} catch (e) {
notifications.toasts.addDanger({
title: API_FAILED_MESSAGE,
text: e.body.message,
});
}
setIsLoading(false);
};
const onChangeItemsPerPage = useCallback(
(pageSize: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
pageSize,
}));
},
[setPagination]
);
const onChangePage = useCallback(
(pageIndex: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex,
}));
},
[setPagination]
);
const onTimeChange = useCallback(
({ start, end, isInvalid }: OnTimeChangeProps) => {
if (isInvalid) {
return;
}
setDateStart(start);
setDateEnd(end);
},
[setDateStart, setDateEnd]
);
const onRefresh = () => {
loadEventLogs();
};
const onFilterChange = useCallback(
(newFilter: string[]) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
}));
setFilter(newFilter);
},
[setPagination, setFilter]
);
const onFlyoutOpen = useCallback((runLog: IExecutionLog) => {
setIsFlyoutOpen(true);
setSelectedRunLog(runLog);
}, []);
const onFlyoutClose = useCallback(() => {
setIsFlyoutOpen(false);
setSelectedRunLog(undefined);
}, []);
const onSearchChange = useCallback(
(e) => {
if (e.target.value === '') {
setSearchText('');
}
setSearch(e.target.value);
},
[setSearchText, setSearch]
);
const onKeyUp = useCallback(
(e) => {
if (e.key === 'Enter') {
setSearchText(search);
}
},
[search, setSearchText]
);
const renderList = () => {
if (!logs) {
return <CenterJustifiedSpinner />;
}
return (
<>
{isLoading && (
<EuiProgress size="xs" color="accent" data-test-subj="ruleEventLogListProgressBar" />
)}
<RuleEventLogDataGrid
logs={logs}
pagination={pagination}
sortingColumns={sortingColumns}
visibleColumns={visibleColumns}
dateFormat={dateFormat}
selectedRunLog={selectedRunLog}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={onChangePage}
onFlyoutOpen={onFlyoutOpen}
onFilterChange={setFilter}
setVisibleColumns={setVisibleColumns}
setSortingColumns={setSortingColumns}
/>
</>
);
};
useEffect(() => {
loadEventLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sortingColumns,
dateStart,
dateEnd,
filter,
pagination.pageIndex,
pagination.pageSize,
searchText,
]);
useEffect(() => {
if (isInitialized.current) {
loadEventLogs();
}
isInitialized.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken]);
useEffect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(visibleColumns));
}, [localStorageKey, visibleColumns]);
return (
<div style={ruleEventListContainerStyle} data-test-subj="ruleEventLogListContainer">
<EuiSpacer />
<RuleExecutionSummaryAndChartWithApi
rule={rule}
ruleId={ruleId}
ruleType={ruleType}
ruleSummary={ruleSummary}
numberOfExecutions={numberOfExecutions}
@ -349,57 +77,15 @@ export const RuleEventLogList = <T extends RuleEventLogListOptions>(
requestRefresh={requestRefresh}
fetchRuleSummary={fetchRuleSummary}
/>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFieldSearch
fullWidth
isClearable
value={search}
onChange={onSearchChange}
onKeyUp={onKeyUp}
placeholder={SEARCH_PLACEHOLDER}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RuleEventLogListStatusFilter selectedOptions={filter} onChange={onFilterChange} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
data-test-subj="ruleEventLogListDatePicker"
width="auto"
isLoading={isLoading}
start={dateStart}
end={dateEnd}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
updateButtonProps={updateButtonProps}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{renderList()}
{isOnLastPage && (
<RefineSearchPrompt
documentSize={actualTotalItemCount}
visibleDocumentSize={MAX_RESULTS}
backToTopAnchor="rule_event_log_list"
/>
)}
{isFlyoutOpen && selectedRunLog && (
<RuleActionErrorLogFlyout
rule={rule}
runLog={selectedRunLog}
refreshToken={refreshToken}
onClose={onFlyoutClose}
/>
)}
<RuleEventLogListTableWithApi
localStorageKey={localStorageKey}
ruleId={ruleId}
refreshToken={refreshToken}
overrideLoadExecutionLogAggregations={loadExecutionLogAggregations}
/>
</div>
);
};
export const RuleEventLogListWithApi = withBulkRuleOperations(RuleEventLogList);
// eslint-disable-next-line import/no-default-export
export { RuleEventLogListWithApi as default };
export { RuleEventLogList as default };

View file

@ -5,9 +5,12 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import moment from 'moment';
import type { EcsEventOutcome } from '@kbn/core/server';
import { EuiLink } from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { routeToRuleDetails } from '../../../constants';
import { formatRuleAlertCount } from '../../../../common/lib/format_rule_alert_count';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format';
@ -26,10 +29,17 @@ interface RuleEventLogListCellRendererProps {
version?: string;
value?: string;
dateFormat?: string;
ruleId?: string;
}
export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => {
const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT } = props;
const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT, ruleId } = props;
const history = useHistory();
const onClickRuleName = useCallback(
() => ruleId && history.push(routeToRuleDetails.replace(':ruleId', ruleId)),
[ruleId, history]
);
if (typeof value === 'undefined') {
return null;
@ -43,6 +53,10 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer
return <>{moment(value).format(dateFormat)}</>;
}
if (columnId === 'rule_name' && ruleId) {
return <EuiLink onClick={onClickRuleName}>{value}</EuiLink>;
}
if (RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS.includes(columnId)) {
return <>{formatRuleAlertCount(value, version)}</>;
}

View file

@ -0,0 +1,396 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import {
EuiFieldSearch,
EuiFlexItem,
EuiFlexGroup,
EuiProgress,
EuiSpacer,
EuiDataGridSorting,
Pagination,
EuiSuperDatePicker,
OnTimeChangeProps,
} from '@elastic/eui';
import { IExecutionLog } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import {
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
LOCKED_COLUMNS,
} from '../../../constants';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogDataGrid } from './rule_event_log_data_grid';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { RuleActionErrorLogFlyout } from './rule_action_error_log_flyout';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { LoadExecutionLogAggregationsProps } from '../../../lib/rule_api';
import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
const getParsedDate = (date: string) => {
if (date.includes('now')) {
return datemath.parse(date)?.format() || date;
}
return date;
};
const API_FAILED_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError',
{
defaultMessage: 'Failed to fetch execution history',
}
);
const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.searchPlaceholder',
{
defaultMessage: 'Search event log message',
}
);
const RULE_EVENT_LOG_LIST_STORAGE_KEY = 'xpack.triggersActionsUI.ruleEventLogList.initialColumns';
const getDefaultColumns = (columns: string[]) => {
const columnsWithoutLockedColumn = columns.filter((column) => !LOCKED_COLUMNS.includes(column));
return [...LOCKED_COLUMNS, ...columnsWithoutLockedColumn];
};
const updateButtonProps = {
iconOnly: true,
fill: false,
};
const MAX_RESULTS = 1000;
export type RuleEventLogListOptions = 'stackManagement' | 'default';
export type RuleEventLogListCommonProps = {
ruleId: string;
localStorageKey?: string;
refreshToken?: number;
initialPageSize?: number;
// Duplicating these properties is extremely silly but it's the only way to get Jest to cooperate with the way this component is structured
overrideLoadExecutionLogAggregations?: RuleApis['loadExecutionLogAggregations'];
overrideLoadGlobalExecutionLogAggregations?: RuleApis['loadGlobalExecutionLogAggregations'];
hasRuleNames?: boolean;
} & Pick<RuleApis, 'loadExecutionLogAggregations' | 'loadGlobalExecutionLogAggregations'>;
export type RuleEventLogListTableProps<T extends RuleEventLogListOptions = 'default'> =
T extends 'default'
? RuleEventLogListCommonProps
: T extends 'stackManagement'
? RuleEventLogListCommonProps
: never;
export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
props: RuleEventLogListTableProps<T>
) => {
const {
ruleId,
localStorageKey = RULE_EVENT_LOG_LIST_STORAGE_KEY,
refreshToken,
loadGlobalExecutionLogAggregations,
loadExecutionLogAggregations,
overrideLoadGlobalExecutionLogAggregations,
overrideLoadExecutionLogAggregations,
initialPageSize = 10,
hasRuleNames = false,
} = props;
const { uiSettings, notifications } = useKibana().services;
const [searchText, setSearchText] = useState<string>('');
const [search, setSearch] = useState<string>('');
const [isFlyoutOpen, setIsFlyoutOpen] = useState<boolean>(false);
const [selectedRunLog, setSelectedRunLog] = useState<IExecutionLog | undefined>();
// Data grid states
const [logs, setLogs] = useState<IExecutionLog[]>();
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
return getDefaultColumns(
JSON.parse(localStorage.getItem(localStorageKey) ?? 'null') || hasRuleNames
? GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS
: RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS
);
});
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [filter, setFilter] = useState<string[]>([]);
const [actualTotalItemCount, setActualTotalItemCount] = useState<number>(0);
const [pagination, setPagination] = useState<Pagination>({
pageIndex: 0,
pageSize: initialPageSize,
totalItemCount: 0,
});
// Date related states
const [isLoading, setIsLoading] = useState<boolean>(false);
const [dateStart, setDateStart] = useState<string>('now-24h');
const [dateEnd, setDateEnd] = useState<string>('now');
const [dateFormat] = useState(() => uiSettings?.get('dateFormat'));
const [commonlyUsedRanges] = useState(() => {
return (
uiSettings
?.get('timepicker:quickRanges')
?.map(({ from, to, display }: { from: string; to: string; display: string }) => ({
start: from,
end: to,
label: display,
})) || []
);
});
const isInitialized = useRef(false);
const isOnLastPage = useMemo(() => {
const { pageIndex, pageSize } = pagination;
return (pageIndex + 1) * pageSize >= MAX_RESULTS;
}, [pagination]);
// Formats the sort columns to be consumed by the API endpoint
const formattedSort = useMemo(() => {
return sortingColumns.map(({ id: sortId, direction }) => ({
[sortId]: {
order: direction,
},
}));
}, [sortingColumns]);
const loadLogsFn = useMemo(() => {
if (ruleId === '*') {
return overrideLoadGlobalExecutionLogAggregations ?? loadGlobalExecutionLogAggregations;
}
return overrideLoadExecutionLogAggregations ?? loadExecutionLogAggregations;
}, [
ruleId,
overrideLoadExecutionLogAggregations,
overrideLoadGlobalExecutionLogAggregations,
loadExecutionLogAggregations,
loadGlobalExecutionLogAggregations,
]);
const loadEventLogs = async () => {
if (!loadLogsFn) {
return;
}
setIsLoading(true);
try {
const result = await loadLogsFn({
id: ruleId,
sort: formattedSort as LoadExecutionLogAggregationsProps['sort'],
outcomeFilter: filter,
message: searchText,
dateStart: getParsedDate(dateStart),
dateEnd: getParsedDate(dateEnd),
page: pagination.pageIndex,
perPage: pagination.pageSize,
});
setLogs(result.data);
setPagination({
...pagination,
totalItemCount: Math.min(result.total, MAX_RESULTS),
});
setActualTotalItemCount(result.total);
} catch (e) {
notifications.toasts.addDanger({
title: API_FAILED_MESSAGE,
text: e.body?.message ?? e,
});
}
setIsLoading(false);
};
const onChangeItemsPerPage = useCallback(
(pageSize: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
pageSize,
}));
},
[setPagination]
);
const onChangePage = useCallback(
(pageIndex: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex,
}));
},
[setPagination]
);
const onTimeChange = useCallback(
({ start, end, isInvalid }: OnTimeChangeProps) => {
if (isInvalid) {
return;
}
setDateStart(start);
setDateEnd(end);
},
[setDateStart, setDateEnd]
);
const onRefresh = () => {
loadEventLogs();
};
const onFilterChange = useCallback(
(newFilter: string[]) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
}));
setFilter(newFilter);
},
[setPagination, setFilter]
);
const onFlyoutOpen = useCallback((runLog: IExecutionLog) => {
setIsFlyoutOpen(true);
setSelectedRunLog(runLog);
}, []);
const onFlyoutClose = useCallback(() => {
setIsFlyoutOpen(false);
setSelectedRunLog(undefined);
}, []);
const onSearchChange = useCallback(
(e) => {
if (e.target.value === '') {
setSearchText('');
}
setSearch(e.target.value);
},
[setSearchText, setSearch]
);
const onKeyUp = useCallback(
(e) => {
if (e.key === 'Enter') {
setSearchText(search);
}
},
[search, setSearchText]
);
const renderList = () => {
if (!logs) {
return <CenterJustifiedSpinner />;
}
return (
<>
{isLoading && (
<EuiProgress size="xs" color="accent" data-test-subj="ruleEventLogListProgressBar" />
)}
<RuleEventLogDataGrid
logs={logs}
pagination={pagination}
sortingColumns={sortingColumns}
visibleColumns={visibleColumns}
dateFormat={dateFormat}
selectedRunLog={selectedRunLog}
showRuleNameAndIdColumns={hasRuleNames}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={onChangePage}
onFlyoutOpen={onFlyoutOpen}
onFilterChange={setFilter}
setVisibleColumns={setVisibleColumns}
setSortingColumns={setSortingColumns}
/>
</>
);
};
useEffect(() => {
loadEventLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sortingColumns,
dateStart,
dateEnd,
filter,
pagination.pageIndex,
pagination.pageSize,
searchText,
]);
useEffect(() => {
if (isInitialized.current) {
loadEventLogs();
}
isInitialized.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken]);
useEffect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(visibleColumns));
}, [localStorageKey, visibleColumns]);
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFieldSearch
fullWidth
isClearable
value={search}
onChange={onSearchChange}
onKeyUp={onKeyUp}
placeholder={SEARCH_PLACEHOLDER}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RuleEventLogListStatusFilter selectedOptions={filter} onChange={onFilterChange} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
data-test-subj="ruleEventLogListDatePicker"
width="auto"
isLoading={isLoading}
start={dateStart}
end={dateEnd}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
updateButtonProps={updateButtonProps}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{renderList()}
{isOnLastPage && (
<RefineSearchPrompt
documentSize={actualTotalItemCount}
visibleDocumentSize={MAX_RESULTS}
backToTopAnchor="rule_event_log_list"
/>
)}
{isFlyoutOpen && selectedRunLog && (
<RuleActionErrorLogFlyout
runLog={selectedRunLog}
refreshToken={refreshToken}
onClose={onFlyoutClose}
/>
)}
</>
);
};
export const RuleEventLogListTableWithApi = withBulkRuleOperations(RuleEventLogListTable);
// eslint-disable-next-line import/no-default-export
export { RuleEventLogListTableWithApi as default };

View file

@ -44,7 +44,7 @@ describe('rule_execution_summary_and_chart', () => {
it('becomes a stateless component when "fetchRuleSummary" is false', async () => {
const wrapper = mountWithIntl(
<RuleExecutionSummaryAndChart
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
ruleSummary={mockRuleSummary()}
numberOfExecutions={60}
@ -100,7 +100,7 @@ describe('rule_execution_summary_and_chart', () => {
it('becomes a container component when "fetchRuleSummary" is true', async () => {
const wrapper = mountWithIntl(
<RuleExecutionSummaryAndChart
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
fetchRuleSummary={true}
loadRuleSummary={loadRuleSummaryMock}
@ -154,7 +154,7 @@ describe('rule_execution_summary_and_chart', () => {
const wrapper = mountWithIntl(
<RuleExecutionSummaryAndChart
rule={ruleMock}
ruleId={ruleMock.id}
ruleType={ruleType}
fetchRuleSummary={true}
loadRuleSummary={loadRuleSummaryMock}

View file

@ -8,7 +8,7 @@
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPanel, EuiStat, EuiFlexItem, EuiFlexGroup, EuiIconTip } from '@elastic/eui';
import { Rule, RuleSummary, RuleType } from '../../../../types';
import { RuleSummary, RuleType } from '../../../../types';
import { useKibana } from '../../../../common/lib/kibana';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { ExecutionDurationChart } from '../../common/components/execution_duration_chart';
@ -24,7 +24,7 @@ import {
export const DEFAULT_NUMBER_OF_EXECUTIONS = 60;
type RuleExecutionSummaryAndChartProps = {
rule: Rule;
ruleId: string;
ruleType: RuleType;
ruleSummary?: RuleSummary;
numberOfExecutions?: number;
@ -37,7 +37,7 @@ type RuleExecutionSummaryAndChartProps = {
export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChartProps) => {
const {
rule,
ruleId,
ruleType,
ruleSummary,
refreshToken,
@ -103,7 +103,7 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart
}
setInternalIsLoadingRuleSummary(true);
try {
const loadedSummary = await loadRuleSummary(rule.id, computedNumberOfExecutions);
const loadedSummary = await loadRuleSummary(ruleId, computedNumberOfExecutions);
setInternalRuleSummary(loadedSummary);
} catch (e) {
toasts.addDanger({
@ -124,7 +124,7 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart
useEffect(() => {
getRuleSummary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rule, computedNumberOfExecutions]);
}, [ruleId, computedNumberOfExecutions]);
useEffect(() => {
if (isInitialized.current) {

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function globalExecutionLogTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const retry = getService('retry');
describe('globalExecutionLog', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
it('should return logs only from the current space', async () => {
const startTime = new Date().toISOString();
const spaceId = UserAtSpaceScenarios[1].space.id;
const user = UserAtSpaceScenarios[1].user;
const response = await supertest
.post(`${getUrlPrefix(spaceId)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.noop',
schedule: { interval: '1s' },
throttle: null,
})
);
expect(response.status).to.eql(200);
const alertId = response.body.id;
objectRemover.add(spaceId, alertId, 'rule', 'alerting');
const spaceId2 = UserAtSpaceScenarios[4].space.id;
const response2 = await supertest
.post(`${getUrlPrefix(spaceId2)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.noop',
schedule: { interval: '1s' },
throttle: null,
})
);
expect(response2.status).to.eql(200);
const alertId2 = response2.body.id;
objectRemover.add(spaceId2, alertId2, 'rule', 'alerting');
const logs = await retry.try(async () => {
// there can be a successful execute before the error one
const logResponse = await supertestWithoutAuth
.get(
`${getUrlPrefix(
spaceId
)}/internal/alerting/_global_execution_logs?date_start=${startTime}&date_end=9999-12-31T23:59:59Z&per_page=50&page=1`
)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password);
expect(logResponse.statusCode).to.be(200);
return logResponse.body.data;
});
// Filter out any excess logs from rules not created by this test
const sanitizedLogs = logs.filter((l: any) => [alertId, alertId2].includes(l.rule_id));
const allLogsSpace0 = sanitizedLogs.every((l: any) => l.rule_id === alertId);
expect(allLogsSpace0).to.be(true);
});
});
}

View file

@ -44,6 +44,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./health'));
loadTestFile(require.resolve('./excluded'));
loadTestFile(require.resolve('./snooze'));
loadTestFile(require.resolve('./global_execution_log'));
});
});
}