[ResponseOps] Execution log - data grid component, date picker, and status filter (#128183)

* Event log and alerts tab in rule summary

* Fix tests

* Event log implementation in rule summary page

* Unit testing

* run lint

* rule event log unit tests

* Load execution API unit test

* With bulk rule api test update

* Fix lint

* Address comments

* Fix feature flag test

* Fix import type linting

* Integration test and fixed lint

* Lazy load execution log list

* Bump up triggers_actions_ui limits.yml size to 100kbs

* Address comments

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2022-03-28 13:08:25 -07:00 committed by GitHub
parent d8323c2246
commit 4235f8157e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 2133 additions and 233 deletions

View file

@ -56,7 +56,7 @@ pageLoadAssetSize:
telemetry: 51957
telemetryManagementSection: 38586
transform: 41007
triggersActionsUi: 100000
triggersActionsUi: 102400
upgradeAssistant: 81241
uptime: 40825
urlForwarding: 32579

View file

@ -0,0 +1,40 @@
/*
* 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.
*/
export const executionLogSortableColumns = [
'timestamp',
'execution_duration',
'total_search_duration',
'es_search_duration',
'schedule_delay',
'num_triggered_actions',
] as const;
export type ExecutionLogSortFields = typeof executionLogSortableColumns[number];
export interface IExecutionLog {
id: string;
timestamp: string;
duration_ms: number;
status: string;
message: string;
num_active_alerts: number;
num_new_alerts: number;
num_recovered_alerts: number;
num_triggered_actions: number;
num_succeeded_actions: number;
num_errored_actions: number;
total_search_duration_ms: number;
es_search_duration_ms: number;
schedule_delay_ms: number;
timed_out: boolean;
}
export interface IExecutionLogResult {
total: number;
data: IExecutionLog[];
}

View file

@ -20,6 +20,7 @@ export * from './builtin_action_groups';
export * from './disabled_action_groups';
export * from './alert_notify_when_type';
export * from './parse_duration';
export * from './execution_log_types';
export interface AlertingFrameworkHealth {
isSufficientlySecure: boolean;

View file

@ -10,6 +10,7 @@ import Boom from '@hapi/boom';
import { flatMap, get } from 'lodash';
import { parseDuration } from '.';
import { AggregateEventsBySavedObjectResult } from '../../../event_log/server';
import { IExecutionLog, IExecutionLogResult } from '../../common';
const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions
@ -29,29 +30,6 @@ const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid';
const Millis2Nanos = 1000 * 1000;
export interface IExecutionLog {
id: string;
timestamp: string;
duration_ms: number;
status: string;
message: string;
num_active_alerts: number;
num_new_alerts: number;
num_recovered_alerts: number;
num_triggered_actions: number;
num_succeeded_actions: number;
num_errored_actions: number;
total_search_duration_ms: number;
es_search_duration_ms: number;
schedule_delay_ms: number;
timed_out: boolean;
}
export interface IExecutionLogResult {
total: number;
data: IExecutionLog[];
}
export const EMPTY_EXECUTION_LOG_RESULT = {
total: 0,
data: [],

View file

@ -88,8 +88,8 @@ import { AlertingRulesConfig } from '../config';
import {
formatExecutionLogResult,
getExecutionLogAggregation,
IExecutionLogResult,
} from '../lib/get_execution_log_aggregation';
import { IExecutionLogResult } from '../../common';
import { validateSnoozeDate } from '../lib/validate_snooze_date';
import { RuleMutedError } from '../lib/errors/rule_muted';
import {

View file

@ -13,7 +13,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues;
*/
export const allowedExperimentalValues = Object.freeze({
rulesListDatagrid: true,
rulesDetailLogs: false,
rulesDetailLogs: true,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -38,3 +38,35 @@ export enum SORT_ORDERS {
export const DEFAULT_SEARCH_PAGE_SIZE: number = 10;
export const DEFAULT_RULE_INTERVAL = '1m';
export const RULE_EXECUTION_LOG_COLUMN_IDS = [
'id',
'timestamp',
'execution_duration',
'status',
'message',
'num_active_alerts',
'num_new_alerts',
'num_recovered_alerts',
'num_triggered_actions',
'num_succeeded_actions',
'num_errored_actions',
'total_search_duration',
'es_search_duration',
'schedule_delay',
'timed_out',
] as const;
export const RULE_EXECUTION_LOG_DURATION_COLUMNS = [
'execution_duration',
'total_search_duration',
'es_search_duration',
'schedule_delay',
];
export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [
'timestamp',
'execution_duration',
'status',
'message',
];

View file

@ -19,7 +19,9 @@ describe('monitoring_utils', () => {
it('should return a formatted duration', () => {
expect(getFormattedDuration(0)).toEqual('00:00');
expect(getFormattedDuration(100.111)).toEqual('00:00');
expect(getFormattedDuration(500)).toEqual('00:01');
expect(getFormattedDuration(50000)).toEqual('00:50');
expect(getFormattedDuration(59900)).toEqual('01:00');
expect(getFormattedDuration(500000)).toEqual('08:20');
expect(getFormattedDuration(5000000)).toEqual('83:20');
expect(getFormattedDuration(50000000)).toEqual('833:20');

View file

@ -16,10 +16,21 @@ export function getFormattedDuration(value: number) {
if (!value) {
return '00:00';
}
const duration = moment.duration(value);
const minutes = Math.floor(duration.asMinutes()).toString().padStart(2, '0');
const seconds = duration.seconds().toString().padStart(2, '0');
return `${minutes}:${seconds}`;
let minutes = Math.floor(duration.asMinutes());
let seconds = duration.seconds();
const ms = duration.milliseconds();
if (ms >= 500) {
seconds += 1;
if (seconds === 60) {
seconds = 0;
minutes += 1;
}
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
export function getFormattedMilliseconds(value: number) {

View file

@ -19,6 +19,8 @@ export { muteRule, muteRules } from './mute';
export { loadRuleTypes } from './rule_types';
export { loadRules } from './rules';
export { loadRuleState } from './state';
export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations';
export { loadExecutionLogAggregations } from './load_execution_log_aggregations';
export { unmuteAlertInstance } from './unmute_alert';
export { unmuteRule, unmuteRules } from './unmute';
export { updateRule } from './update';

View file

@ -0,0 +1,96 @@
/*
* 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks';
import { loadExecutionLogAggregations, SortField } from './load_execution_log_aggregations';
const http = httpServiceMock.createStartContract();
const mockResponse = {
data: [
{
duration_ms: 50,
es_search_duration_ms: 1,
id: '13af2138-1c9d-4d34-95c1-c25fbfbb8eeb',
message: "rule executed: .index-threshold:c8f2ccb0-aac4-11ec-a5ae-2101bb96406d: 'test'",
num_active_alerts: 0,
num_errored_actions: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_succeeded_actions: 0,
num_triggered_actions: 0,
schedule_delay_ms: 1623,
status: 'success',
timed_out: false,
timestamp: '2022-03-23T16:17:53.482Z',
total_search_duration_ms: 4,
},
],
total: 5,
};
describe('loadExecutionLogAggregations', () => {
test('should call load execution log aggregation API', async () => {
http.get.mockResolvedValueOnce(mockResponse);
const sortTimestamp = {
timestamp: {
order: 'asc',
},
} as SortField;
const result = await loadExecutionLogAggregations({
id: 'test-id',
dateStart: '2022-03-23T16:17:53.482Z',
dateEnd: '2022-03-23T16:17:53.482Z',
filter: ['success', 'unknown'],
perPage: 10,
page: 0,
sort: [sortTimestamp],
http,
});
expect(result).toEqual({
...mockResponse,
data: [
{
execution_duration: 50,
es_search_duration: 1,
id: '13af2138-1c9d-4d34-95c1-c25fbfbb8eeb',
message: "rule executed: .index-threshold:c8f2ccb0-aac4-11ec-a5ae-2101bb96406d: 'test'",
num_active_alerts: 0,
num_errored_actions: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_succeeded_actions: 0,
num_triggered_actions: 0,
schedule_delay: 1623,
status: 'success',
timed_out: false,
timestamp: '2022-03-23T16:17:53.482Z',
total_search_duration: 4,
},
],
});
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rule/test-id/_execution_log",
Object {
"query": Object {
"date_end": "2022-03-23T16:17:53.482Z",
"date_start": "2022-03-23T16:17:53.482Z",
"filter": "success OR unknown",
"page": 1,
"per_page": 10,
"sort": "[{\\"timestamp\\":{\\"order\\":\\"asc\\"}}]",
},
},
]
`);
});
});

View file

@ -0,0 +1,97 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import { HttpSetup } from 'kibana/public';
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import {
IExecutionLogResult,
IExecutionLog,
ExecutionLogSortFields,
} from '../../../../../alerting/common';
import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common';
const getRenamedLog = (data: IExecutionLog) => {
const {
duration_ms,
total_search_duration_ms,
es_search_duration_ms,
schedule_delay_ms,
...rest
} = data;
return {
execution_duration: data.duration_ms,
total_search_duration: data.total_search_duration_ms,
es_search_duration: data.es_search_duration_ms,
schedule_delay: data.schedule_delay_ms,
...rest,
};
};
const rewriteBodyRes: RewriteRequestCase<IExecutionLogResult> = ({ data, total }: any) => ({
data: data.map((log: IExecutionLog) => getRenamedLog(log)),
total,
});
const getFilter = (filter: string[] | undefined) => {
if (!filter || !filter.length) {
return;
}
return filter.join(' OR ');
};
export type SortField = Record<
ExecutionLogSortFields,
{
order: SortOrder;
}
>;
export interface LoadExecutionLogAggregationsProps {
id: string;
dateStart: string;
dateEnd?: string;
filter?: string[];
perPage?: number;
page?: number;
sort?: SortField[];
}
export const loadExecutionLogAggregations = async ({
id,
http,
dateStart,
dateEnd,
filter,
perPage = 10,
page = 0,
sort = [],
}: LoadExecutionLogAggregationsProps & { http: HttpSetup }) => {
const sortField: any[] = sort;
const result = await http.get<AsApiContract<IExecutionLogResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${id}/_execution_log`,
{
query: {
date_start: dateStart,
date_end: dateEnd,
filter: getFilter(filter),
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

@ -10,6 +10,8 @@ import { shallow, mount } from 'enzyme';
import uuid from 'uuid';
import { withBulkRuleOperations, ComponentOpts } from './with_bulk_rule_api_operations';
import * as ruleApi from '../../../lib/rule_api';
import { SortField } from '../../../lib/rule_api/load_execution_log_aggregations';
import { Rule } from '../../../../types';
import { useKibana } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
@ -37,6 +39,7 @@ describe('with_bulk_rule_api_operations', () => {
expect(typeof props.loadRule).toEqual('function');
expect(typeof props.loadRuleTypes).toEqual('function');
expect(typeof props.resolveRule).toEqual('function');
expect(typeof props.loadExecutionLogAggregations).toEqual('function');
return <div />;
};
@ -246,6 +249,40 @@ describe('with_bulk_rule_api_operations', () => {
expect(ruleApi.loadRuleTypes).toHaveBeenCalledTimes(1);
expect(ruleApi.loadRuleTypes).toHaveBeenCalledWith({ http });
});
it('loadExecutionLogAggregations calls the loadExecutionLogAggregations api', () => {
const { http } = useKibanaMock().services;
const sortTimestamp = {
timestamp: {
order: 'asc',
},
} as SortField;
const callProps = {
id: 'test-id',
dateStart: '2022-03-23T16:17:53.482Z',
dateEnd: '2022-03-23T16:17:53.482Z',
filter: ['success', 'unknown'],
perPage: 10,
page: 0,
sort: [sortTimestamp],
};
const ComponentToExtend = ({ loadExecutionLogAggregations }: ComponentOpts) => {
return <button onClick={() => loadExecutionLogAggregations(callProps)}>{'call api'}</button>;
};
const ExtendedComponent = withBulkRuleOperations(ComponentToExtend);
const component = mount(<ExtendedComponent />);
component.find('button').simulate('click');
expect(ruleApi.loadExecutionLogAggregations).toHaveBeenCalledTimes(1);
expect(ruleApi.loadExecutionLogAggregations).toHaveBeenCalledWith({
...callProps,
http,
});
});
});
function mockRule(overloads: Partial<Rule> = {}): Rule {

View file

@ -33,7 +33,10 @@ import {
loadRuleTypes,
alertingFrameworkHealth,
resolveRule,
loadExecutionLogAggregations,
LoadExecutionLogAggregationsProps,
} from '../../../lib/rule_api';
import { IExecutionLogResult } from '../../../../../../alerting/common';
import { useKibana } from '../../../../common/lib/kibana';
export interface ComponentOpts {
@ -59,6 +62,9 @@ export interface ComponentOpts {
loadRuleState: (id: Rule['id']) => Promise<RuleTaskState>;
loadRuleSummary: (id: Rule['id'], numberOfExecutions?: number) => Promise<RuleSummary>;
loadRuleTypes: () => Promise<RuleType[]>;
loadExecutionLogAggregations: (
props: LoadExecutionLogAggregationsProps
) => Promise<IExecutionLogResult>;
getHealth: () => Promise<AlertingFrameworkHealth>;
resolveRule: (id: Rule['id']) => Promise<ResolvedRule>;
}
@ -131,6 +137,12 @@ export function withBulkRuleOperations<T>(
loadRuleSummary({ http, ruleId, numberOfExecutions })
}
loadRuleTypes={async () => loadRuleTypes({ http })}
loadExecutionLogAggregations={async (loadProps: LoadExecutionLogAggregationsProps) =>
loadExecutionLogAggregations({
...loadProps,
http,
})
}
resolveRule={async (ruleId: Rule['id']) => resolveRule({ http, ruleId })}
getHealth={async () => alertingFrameworkHealth({ http })}
/>

View file

@ -8,7 +8,7 @@
import React, { useState } from 'react';
import { EuiSwitch, EuiLoadingSpinner } from '@elastic/eui';
import { AlertListItem } from './rule';
import { AlertListItem } from './types';
interface ComponentOpts {
alert: AlertListItem;

View file

@ -10,13 +10,20 @@ import uuid from 'uuid';
import { shallow } from 'enzyme';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { RuleComponent, AlertListItem, alertToListItem } from './rule';
import { RuleComponent, alertToListItem } from './rule';
import { AlertListItem } from './types';
import { RuleAlertList } from './rule_alert_list';
import { Rule, RuleSummary, AlertStatus, RuleType } from '../../../../types';
import { EuiBasicTable } from '@elastic/eui';
import { ExecutionDurationChart } from '../../common/components/execution_duration_chart';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
const fakeNow = new Date('2020-02-09T23:15:41.941Z');
const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z');
@ -29,12 +36,16 @@ const mockAPIs = {
};
beforeAll(() => {
jest.resetAllMocks();
jest.clearAllMocks();
global.Date.now = jest.fn(() => fakeNow.getTime());
});
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
});
describe('rules', () => {
it('render a list of rules', () => {
it('render a list of rules', async () => {
const rule = mockRule();
const ruleType = mockRuleType();
const ruleSummary = mockRuleSummary({
@ -59,19 +70,22 @@ describe('rules', () => {
alertToListItem(fakeNow.getTime(), ruleType, 'first_rule', ruleSummary.alerts.first_rule),
];
expect(
shallow(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
ruleSummary={ruleSummary}
readOnly={false}
/>
)
.find(EuiBasicTable)
.prop('items')
).toEqual(rules);
const wrapper = mountWithIntl(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
ruleSummary={ruleSummary}
readOnly={false}
/>
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve));
wrapper.update();
});
expect(wrapper.find(RuleAlertList).prop('items')).toEqual(rules);
});
it('render a hidden field with duration epoch', () => {
@ -95,7 +109,7 @@ describe('rules', () => {
).toEqual(fake2MinutesAgo.getTime());
});
it('render all active rules', () => {
it('render all active rules', async () => {
const rule = mockRule();
const ruleType = mockRuleType();
const alerts: Record<string, AlertStatus> = {
@ -108,27 +122,31 @@ describe('rules', () => {
muted: false,
},
};
expect(
shallow(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
readOnly={false}
ruleSummary={mockRuleSummary({
alerts,
})}
/>
)
.find(EuiBasicTable)
.prop('items')
).toEqual([
const wrapper = mountWithIntl(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
readOnly={false}
ruleSummary={mockRuleSummary({
alerts,
})}
/>
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve));
wrapper.update();
});
expect(wrapper.find(RuleAlertList).prop('items')).toEqual([
alertToListItem(fakeNow.getTime(), ruleType, 'us-central', alerts['us-central']),
alertToListItem(fakeNow.getTime(), ruleType, 'us-east', alerts['us-east']),
]);
});
it('render all inactive rules', () => {
it('render all inactive rules', async () => {
const rule = mockRule({
mutedInstanceIds: ['us-west', 'us-east'],
});
@ -136,30 +154,33 @@ describe('rules', () => {
const ruleUsWest: AlertStatus = { status: 'OK', muted: false };
const ruleUsEast: AlertStatus = { status: 'OK', muted: false };
expect(
shallow(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
readOnly={false}
ruleSummary={mockRuleSummary({
alerts: {
'us-west': {
status: 'OK',
muted: false,
},
'us-east': {
status: 'OK',
muted: false,
},
const wrapper = mountWithIntl(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
readOnly={false}
ruleSummary={mockRuleSummary({
alerts: {
'us-west': {
status: 'OK',
muted: false,
},
})}
/>
)
.find(EuiBasicTable)
.prop('items')
).toEqual([
'us-east': {
status: 'OK',
muted: false,
},
},
})}
/>
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve));
wrapper.update();
});
expect(wrapper.find(RuleAlertList).prop('items')).toEqual([
alertToListItem(fakeNow.getTime(), ruleType, 'us-west', ruleUsWest),
alertToListItem(fakeNow.getTime(), ruleType, 'us-east', ruleUsEast),
]);
@ -379,6 +400,64 @@ describe('execution duration overview', () => {
});
});
describe('tabbed content', () => {
it('tabbed content renders when the event log experiment is on', async () => {
// Enable the event log experiment
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
const rule = mockRule();
const ruleType = mockRuleType();
const ruleSummary = mockRuleSummary({
alerts: {
first_rule: {
status: 'OK',
muted: false,
actionGroupId: 'default',
},
second_rule: {
status: 'Active',
muted: false,
actionGroupId: 'action group id unknown',
},
},
});
const wrapper = shallow(
<RuleComponent
{...mockAPIs}
rule={rule}
ruleType={ruleType}
ruleSummary={ruleSummary}
readOnly={false}
/>
);
const tabbedContent = wrapper.find('[data-test-subj="ruleDetailsTabbedContent"]').dive();
// Need to mock this function
(tabbedContent.instance() as any).focusTab = jest.fn();
tabbedContent.update();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve));
tabbedContent.update();
});
expect(tabbedContent.find('[aria-labelledby="rule_event_log_list"]').exists()).toBeTruthy();
expect(tabbedContent.find('[aria-labelledby="rule_alert_list"]').exists()).toBeFalsy();
tabbedContent.find('[data-test-subj="ruleAlertListTab"]').simulate('click');
expect(tabbedContent.find('[aria-labelledby="rule_event_log_list"]').exists()).toBeFalsy();
expect(tabbedContent.find('[aria-labelledby="rule_alert_list"]').exists()).toBeTruthy();
tabbedContent.find('[data-test-subj="eventLogListTab"]').simulate('click');
expect(tabbedContent.find('[aria-labelledby="rule_event_log_list"]').exists()).toBeTruthy();
expect(tabbedContent.find('[aria-labelledby="rule_alert_list"]').exists()).toBeFalsy();
});
});
function mockRule(overloads: Partial<Rule> = {}): Rule {
return {
id: uuid.v4(),

View file

@ -5,37 +5,32 @@
* 2.0.
*/
import React, { useState } from 'react';
import moment, { Duration } from 'moment';
import React, { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiBasicTable,
EuiHealth,
EuiSpacer,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiStat,
EuiIconTip,
EuiTabbedContent,
} from '@elastic/eui';
// @ts-ignore
import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services';
import { padStart, chunk } from 'lodash';
import {
ActionGroup,
AlertExecutionStatusErrorReasons,
AlertStatusValues,
} from '../../../../../../alerting/common';
import { Rule, RuleSummary, AlertStatus, RuleType, Pagination } from '../../../../types';
import { Rule, RuleSummary, AlertStatus, RuleType } from '../../../../types';
import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants';
import './rule.scss';
import { AlertMutedSwitch } from './alert_muted_switch';
import { getHealthColor } from '../../rules_list/components/rule_status_filter';
import {
rulesStatusesTranslationsMapping,
@ -46,6 +41,14 @@ import {
shouldShowDurationWarning,
} from '../../../lib/execution_duration_utils';
import { ExecutionDurationChart } from '../../common/components/execution_duration_chart';
// import { RuleEventLogListWithApi } from './rule_event_log_list';
import { AlertListItem } from './types';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
const RuleEventLogListWithApi = lazy(() => import('./rule_event_log_list'));
const RuleAlertList = lazy(() => import('./rule_alert_list'));
type RuleProps = {
rule: Rule;
@ -59,95 +62,8 @@ type RuleProps = {
isLoadingChart?: boolean;
} & Pick<RuleApis, 'muteAlertInstance' | 'unmuteAlertInstance'>;
export const alertsTableColumns = (
onMuteAction: (alert: AlertListItem) => Promise<void>,
readOnly: boolean
) => [
{
field: 'alert',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.Alert', {
defaultMessage: 'Alert',
}),
sortable: false,
truncateText: true,
width: '45%',
'data-test-subj': 'alertsTableCell-alert',
render: (value: string) => {
return (
<EuiToolTip anchorClassName={'eui-textTruncate'} content={value}>
<span>{value}</span>
</EuiToolTip>
);
},
},
{
field: 'status',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', {
defaultMessage: 'Status',
}),
width: '15%',
render: (value: AlertListItemStatus) => {
return (
<EuiHealth color={value.healthColor} className="alertsList__health">
{value.label}
{value.actionGroup ? ` (${value.actionGroup})` : ``}
</EuiHealth>
);
},
sortable: false,
'data-test-subj': 'alertsTableCell-status',
},
{
field: 'start',
width: '190px',
render: (value: Date | undefined) => {
return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : '';
},
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.start', {
defaultMessage: 'Start',
}),
sortable: false,
'data-test-subj': 'alertsTableCell-start',
},
{
field: 'duration',
render: (value: number) => {
return value ? durationAsString(moment.duration(value)) : '';
},
name: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.duration',
{ defaultMessage: 'Duration' }
),
sortable: false,
width: '80px',
'data-test-subj': 'alertsTableCell-duration',
},
{
field: '',
align: RIGHT_ALIGNMENT,
width: '60px',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.mute', {
defaultMessage: 'Mute',
}),
render: (alert: AlertListItem) => {
return (
<AlertMutedSwitch
disabled={readOnly}
onMuteAction={async () => await onMuteAction(alert)}
alert={alert}
/>
);
},
sortable: false,
'data-test-subj': 'alertsTableCell-actions',
},
];
function durationAsString(duration: Duration): string {
return [duration.hours(), duration.minutes(), duration.seconds()]
.map((value) => padStart(`${value}`, 2, '0'))
.join(':');
}
const EVENT_LOG_LIST_TAB = 'rule_event_log_list';
const ALERT_LIST_TAB = 'rule_alert_list';
export function RuleComponent({
rule,
@ -162,17 +78,10 @@ export function RuleComponent({
durationEpoch = Date.now(),
isLoadingChart,
}: RuleProps) {
const [pagination, setPagination] = useState<Pagination>({
index: 0,
size: DEFAULT_SEARCH_PAGE_SIZE,
});
const alerts = Object.entries(ruleSummary.alerts)
.map(([alertId, alert]) => alertToListItem(durationEpoch, ruleType, alertId, alert))
.sort((leftAlert, rightAlert) => leftAlert.sortPriority - rightAlert.sortPriority);
const pageOfAlerts = getPage(alerts, pagination);
const onMuteAction = async (alert: AlertListItem) => {
await (alert.isMuted
? unmuteAlertInstance(rule, alert.alert)
@ -192,6 +101,44 @@ export function RuleComponent({
? ALERT_STATUS_LICENSE_ERROR
: rulesStatusesTranslationsMapping[rule.executionStatus.status];
const renderRuleAlertList = () => {
return suspendedComponentWithProps(
RuleAlertList,
'xl'
)({
items: alerts,
readOnly,
onMuteAction,
});
};
const tabs = [
{
id: EVENT_LOG_LIST_TAB,
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText', {
defaultMessage: 'Execution History',
}),
'data-test-subj': 'eventLogListTab',
content: suspendedComponentWithProps(RuleEventLogListWithApi, 'xl')({ rule }),
},
{
id: ALERT_LIST_TAB,
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.rule.alertsTabText', {
defaultMessage: 'Alerts',
}),
'data-test-subj': 'ruleAlertListTab',
content: renderRuleAlertList(),
},
];
const renderTabs = () => {
const isEnabled = getIsExperimentalFeatureEnabled('rulesDetailLogs');
if (isEnabled) {
return <EuiTabbedContent data-test-subj="ruleDetailsTabbedContent" tabs={tabs} />;
}
return renderRuleAlertList();
};
return (
<>
<EuiHorizontalRule />
@ -277,50 +224,12 @@ export function RuleComponent({
name="alertsDurationEpoch"
value={durationEpoch}
/>
<EuiBasicTable
items={pageOfAlerts}
pagination={{
pageIndex: pagination.index,
pageSize: pagination.size,
totalItemCount: alerts.length,
}}
onChange={({ page: changedPage }: { page: Pagination }) => {
setPagination(changedPage);
}}
rowProps={() => ({
'data-test-subj': 'alert-row',
})}
cellProps={() => ({
'data-test-subj': 'cell',
})}
columns={alertsTableColumns(onMuteAction, readOnly)}
data-test-subj="alertsList"
tableLayout="fixed"
className="alertsList"
/>
{renderTabs()}
</>
);
}
export const RuleWithApi = withBulkRuleOperations(RuleComponent);
function getPage(items: any[], pagination: Pagination) {
return chunk(items, pagination.size)[pagination.index] || [];
}
interface AlertListItemStatus {
label: string;
healthColor: string;
actionGroup?: string;
}
export interface AlertListItem {
alert: string;
status: AlertListItemStatus;
start?: Date;
duration: number;
isMuted: boolean;
sortPriority: number;
}
const ACTIVE_LABEL = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active',
{ defaultMessage: 'Active' }

View file

@ -0,0 +1,168 @@
/*
* 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, { useMemo, useCallback, useState } from 'react';
import moment, { Duration } from 'moment';
import { padStart, chunk } from 'lodash';
import { EuiHealth, EuiBasicTable, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants';
import { Pagination } from '../../../../types';
import { AlertListItemStatus, AlertListItem } from './types';
import { AlertMutedSwitch } from './alert_muted_switch';
const durationAsString = (duration: Duration): string => {
return [duration.hours(), duration.minutes(), duration.seconds()]
.map((value) => padStart(`${value}`, 2, '0'))
.join(':');
};
const alertsTableColumns = (
onMuteAction: (alert: AlertListItem) => Promise<void>,
readOnly: boolean
) => [
{
field: 'alert',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.Alert', {
defaultMessage: 'Alert',
}),
sortable: false,
truncateText: true,
width: '45%',
'data-test-subj': 'alertsTableCell-alert',
render: (value: string) => {
return (
<EuiToolTip anchorClassName={'eui-textTruncate'} content={value}>
<span>{value}</span>
</EuiToolTip>
);
},
},
{
field: 'status',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', {
defaultMessage: 'Status',
}),
width: '15%',
render: (value: AlertListItemStatus) => {
return (
<EuiHealth color={value.healthColor} className="alertsList__health">
{value.label}
{value.actionGroup ? ` (${value.actionGroup})` : ``}
</EuiHealth>
);
},
sortable: false,
'data-test-subj': 'alertsTableCell-status',
},
{
field: 'start',
width: '190px',
render: (value: Date | undefined) => {
return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : '';
},
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.start', {
defaultMessage: 'Start',
}),
sortable: false,
'data-test-subj': 'alertsTableCell-start',
},
{
field: 'duration',
render: (value: number) => {
return value ? durationAsString(moment.duration(value)) : '';
},
name: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.duration',
{ defaultMessage: 'Duration' }
),
sortable: false,
width: '80px',
'data-test-subj': 'alertsTableCell-duration',
},
{
field: '',
align: RIGHT_ALIGNMENT,
width: '60px',
name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.mute', {
defaultMessage: 'Mute',
}),
render: (alert: AlertListItem) => {
return (
<AlertMutedSwitch
disabled={readOnly}
onMuteAction={async () => await onMuteAction(alert)}
alert={alert}
/>
);
},
sortable: false,
'data-test-subj': 'alertsTableCell-actions',
},
];
interface RuleAlertListProps {
items: AlertListItem[];
readOnly: boolean;
onMuteAction: (alert: AlertListItem) => Promise<void>;
}
const getRowProps = () => ({
'data-test-subj': 'alert-row',
});
const getCellProps = () => ({
'data-test-subj': 'cell',
});
function getPage<T>(items: T[], pagination: Pagination) {
return chunk(items, pagination.size)[pagination.index] || [];
}
export const RuleAlertList = (props: RuleAlertListProps) => {
const { items, readOnly, onMuteAction } = props;
const [pagination, setPagination] = useState<Pagination>({
index: 0,
size: DEFAULT_SEARCH_PAGE_SIZE,
});
const pageOfAlerts = getPage<AlertListItem>(items, pagination);
const paginationOptions = useMemo(() => {
return {
pageIndex: pagination.index,
pageSize: pagination.size,
totalItemCount: items.length,
};
}, [pagination, items]);
const onChange = useCallback(
({ page: changedPage }: { page: Pagination }) => {
setPagination(changedPage);
},
[setPagination]
);
return (
<EuiBasicTable<AlertListItem>
items={pageOfAlerts}
pagination={paginationOptions}
onChange={onChange}
rowProps={getRowProps}
cellProps={getCellProps}
columns={alertsTableColumns(onMuteAction, readOnly)}
data-test-subj="alertsList"
tableLayout="fixed"
className="alertsList"
/>
);
};
// eslint-disable-next-line import/no-default-export
export { RuleAlertList as default };

View file

@ -0,0 +1,504 @@
/*
* 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 from 'react';
import uuid from 'uuid';
import { act } from 'react-dom/test-utils';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { useKibana } from '../../../../common/lib/kibana';
import { EuiSuperDatePicker, EuiDataGrid } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogList } from './rule_event_log_list';
import { RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS } from '../../../constants';
import { Rule } from '../../../../types';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../../../common/lib/kibana');
const mockLogResponse: any = {
data: [
{
id: uuid.v4(),
timestamp: '2022-03-20T07:40:44-07:00',
duration: 5000000,
status: 'success',
message: 'rule execution #1',
num_active_alerts: 2,
num_new_alerts: 4,
num_recovered_alerts: 3,
num_triggered_actions: 10,
num_succeeded_actions: 0,
num_errored_actions: 4,
total_search_duration: 1000000,
es_search_duration: 1400000,
schedule_delay: 2000000,
timed_out: false,
},
{
id: uuid.v4(),
timestamp: '2022-03-20T07:40:45-07:00',
duration: 6000000,
status: 'success',
message: 'rule execution #2',
num_active_alerts: 4,
num_new_alerts: 2,
num_recovered_alerts: 4,
num_triggered_actions: 5,
num_succeeded_actions: 3,
num_errored_actions: 0,
total_search_duration: 300000,
es_search_duration: 300000,
schedule_delay: 300000,
timed_out: false,
},
{
id: uuid.v4(),
timestamp: '2022-03-20T07:40:46-07:00',
duration: 340000,
status: 'failure',
message: 'rule execution #3',
num_active_alerts: 8,
num_new_alerts: 5,
num_recovered_alerts: 0,
num_triggered_actions: 1,
num_succeeded_actions: 1,
num_errored_actions: 4,
total_search_duration: 2300000,
es_search_duration: 2300000,
schedule_delay: 2300000,
timed_out: false,
},
{
id: uuid.v4(),
timestamp: '2022-03-21T07:40:46-07:00',
duration: 3000000,
status: 'unknown',
message: 'rule execution #4',
num_active_alerts: 4,
num_new_alerts: 4,
num_recovered_alerts: 4,
num_triggered_actions: 4,
num_succeeded_actions: 4,
num_errored_actions: 4,
total_search_duration: 400000,
es_search_duration: 400000,
schedule_delay: 400000,
timed_out: false,
},
],
total: 4,
};
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 loadExecutionLogAggregationsMock = jest.fn();
describe('rule_event_log_list', () => {
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.uiSettings.get = jest.fn().mockImplementation((value: string) => {
if (value === 'timepicker:quickRanges') {
return [
{
from: 'now-15m',
to: 'now',
display: 'Last 15 minutes',
},
];
}
});
loadExecutionLogAggregationsMock.mockResolvedValue(mockLogResponse);
});
it('renders correctly', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
// Run the initial load fetch call
expect(loadExecutionLogAggregationsMock).toHaveBeenCalledTimes(1);
expect(loadExecutionLogAggregationsMock).toHaveBeenCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: [],
page: 0,
perPage: 10,
})
);
// Loading
expect(wrapper.find(EuiSuperDatePicker).props().isLoading).toBeTruthy();
// Verify the initial columns are rendered
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS.forEach((column) => {
expect(wrapper.find(`[data-test-subj="dataGridHeaderCell-${column}"]`).exists()).toBeTruthy();
});
// No data initially
expect(wrapper.find('[data-gridcell-column-id="timestamp"]').length).toEqual(1);
// Let the load resolve
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find(EuiSuperDatePicker).props().isLoading).toBeFalsy();
expect(wrapper.find(RuleEventLogListStatusFilter).exists()).toBeTruthy();
expect(wrapper.find('[data-gridcell-column-id="timestamp"]').length).toEqual(5);
expect(wrapper.find(EuiDataGrid).props().rowCount).toEqual(mockLogResponse.total);
});
it('can sort by single and/or multiple column(s)', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
let headerCellButton = wrapper.find('[data-test-subj="dataGridHeaderCell-timestamp"] button');
headerCellButton.simulate('click');
let headerAction = wrapper.find('[data-test-subj="dataGridHeaderCellActionGroup-timestamp"]');
expect(headerAction.exists()).toBeTruthy();
// Sort by the timestamp column
headerAction.find('li').at(1).find('button').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [
{
timestamp: {
order: 'asc',
},
},
],
filter: [],
page: 0,
perPage: 10,
})
);
// Open the popover again
headerCellButton.simulate('click');
headerAction = wrapper.find('[data-test-subj="dataGridHeaderCellActionGroup-timestamp"]');
// Sort by the timestamp column, this time, in the opposite direction
headerAction.find('li').at(2).find('button').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [
{
timestamp: {
order: 'desc',
},
},
],
filter: [],
page: 0,
perPage: 10,
})
);
// Find another column
headerCellButton = wrapper.find(
'[data-test-subj="dataGridHeaderCell-execution_duration"] button'
);
// Open the popover again
headerCellButton.simulate('click');
headerAction = wrapper.find(
'[data-test-subj="dataGridHeaderCellActionGroup-execution_duration"]'
);
// Sort
headerAction.find('li').at(1).find('button').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [
{
timestamp: { order: 'desc' },
},
{
execution_duration: { order: 'asc' },
},
],
filter: [],
page: 0,
perPage: 10,
})
);
});
it('can filter by execution log outcome status', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
// Filter by success
wrapper.find('[data-test-subj="ruleEventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="ruleEventLogStatusFilter-success"]').at(0).simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: ['success'],
page: 0,
perPage: 10,
})
);
// Filter by failure as well
wrapper.find('[data-test-subj="ruleEventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="ruleEventLogStatusFilter-failure"]').at(0).simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: ['success', 'failure'],
page: 0,
perPage: 10,
})
);
});
it('can paginate', async () => {
loadExecutionLogAggregationsMock.mockResolvedValue({
...mockLogResponse,
total: 100,
});
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('.euiPagination').exists()).toBeTruthy();
// Paginate to the next page
wrapper.find('.euiPagination .euiPagination__item a').at(0).simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: [],
page: 1,
perPage: 10,
})
);
// Change the page size
wrapper.find('[data-test-subj="tablePaginationPopoverButton"] button').simulate('click');
wrapper.find('[data-test-subj="tablePagination-50-rows"] button').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: [],
page: 0,
perPage: 50,
})
);
});
it('can filter by start and end date', async () => {
const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0);
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: [],
page: 0,
perPage: 10,
dateStart: '1969-12-30T19:00:00-05:00',
dateEnd: '1969-12-31T19:00:00-05:00',
})
);
wrapper
.find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button')
.simulate('click');
wrapper
.find('[data-test-subj="superDatePickerCommonlyUsed_Last_15 minutes"] button')
.simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
id: mockRule.id,
sort: [],
filter: [],
page: 0,
perPage: 10,
dateStart: '1969-12-31T18:45:00-05:00',
dateEnd: '1969-12-31T19:00:00-05:00',
})
);
nowMock.mockRestore();
});
it('can save display columns to localStorage', async () => {
const wrapper = mountWithIntl(
<RuleEventLogList
rule={mockRule}
loadExecutionLogAggregations={loadExecutionLogAggregationsMock}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(
JSON.parse(
localStorage.getItem('xpack.triggersActionsUI.ruleEventLogList.initialColumns') ?? 'null'
)
).toEqual(RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS);
wrapper.find('[data-test-subj="dataGridColumnSelectorButton"] button').simulate('click');
wrapper
.find(
'[data-test-subj="dataGridColumnSelectorToggleColumnVisibility-num_active_alerts"] button'
)
.simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(
JSON.parse(
localStorage.getItem('xpack.triggersActionsUI.ruleEventLogList.initialColumns') ?? 'null'
)
).toEqual([...RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS, 'num_active_alerts']);
});
});

View file

@ -0,0 +1,446 @@
/*
* 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 } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@elastic/datemath';
import {
EuiDataGrid,
EuiFlexItem,
EuiFlexGroup,
EuiProgress,
EuiSpacer,
EuiDataGridSorting,
Pagination,
EuiSuperDatePicker,
EuiDataGridCellValueElementProps,
OnTimeChangeProps,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS } from '../../../constants';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogListCellRenderer, ColumnId } from './rule_event_log_list_cell_renderer';
import { LoadExecutionLogAggregationsProps } from '../../../lib/rule_api';
import { Rule } from '../../../../types';
import {
IExecutionLog,
executionLogSortableColumns,
ExecutionLogSortFields,
} from '../../../../../../alerting/common';
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 getIsColumnSortable = (columnId: string) => {
return executionLogSortableColumns.includes(columnId as ExecutionLogSortFields);
};
const columns = [
{
id: 'id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id',
{
defaultMessage: 'Id',
}
),
isSortable: getIsColumnSortable('id'),
},
{
id: 'timestamp',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp',
{
defaultMessage: 'Timestamp',
}
),
isSortable: getIsColumnSortable('timestamp'),
initialWidth: 250,
},
{
id: 'execution_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration',
{
defaultMessage: 'Duration',
}
),
isSortable: getIsColumnSortable('execution_duration'),
initialWidth: 100,
},
{
id: 'status',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.status',
{
defaultMessage: 'Status',
}
),
isSortable: getIsColumnSortable('status'),
initialWidth: 100,
},
{
id: 'message',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message',
{
defaultMessage: 'Message',
}
),
isSortable: getIsColumnSortable('message'),
},
{
id: 'num_active_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts',
{
defaultMessage: 'Active alerts',
}
),
isSortable: getIsColumnSortable('num_active_alerts'),
},
{
id: 'num_new_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts',
{
defaultMessage: 'New alerts',
}
),
isSortable: getIsColumnSortable('num_new_alerts'),
},
{
id: 'num_recovered_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts',
{
defaultMessage: 'Recovered alerts',
}
),
isSortable: getIsColumnSortable('num_recovered_alerts'),
},
{
id: 'num_triggered_actions',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActions',
{
defaultMessage: 'Triggered actions',
}
),
isSortable: getIsColumnSortable('num_triggered_actions'),
},
{
id: 'num_succeeded_actions',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActions',
{
defaultMessage: 'Succeeded actions',
}
),
isSortable: getIsColumnSortable('num_succeeded_actions'),
},
{
id: 'num_errored_actions',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActions',
{
defaultMessage: 'Errored actions',
}
),
isSortable: getIsColumnSortable('num_errored_actions'),
},
{
id: 'total_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration',
{
defaultMessage: 'Total search duration',
}
),
isSortable: getIsColumnSortable('total_search_duration'),
},
{
id: 'es_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration',
{
defaultMessage: 'ES search duration',
}
),
isSortable: getIsColumnSortable('es_search_duration'),
},
{
id: 'schedule_delay',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay',
{
defaultMessage: 'Schedule delay',
}
),
isSortable: getIsColumnSortable('schedule_delay'),
},
{
id: 'timed_out',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut',
{
defaultMessage: 'Timed out',
}
),
isSortable: getIsColumnSortable('timed_out'),
},
];
const API_FAILED_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError',
{
defaultMessage: 'Failed to fetch execution history',
}
);
const RULE_EVENT_LOG_LIST_STORAGE_KEY = 'xpack.triggersActionsUI.ruleEventLogList.initialColumns';
const PAGE_SIZE_OPTION = [10, 50, 100];
const updateButtonProps = {
iconOnly: true,
fill: false,
};
export type RuleEventLogListProps = {
rule: Rule;
localStorageKey?: string;
} & Pick<RuleApis, 'loadExecutionLogAggregations'>;
export const RuleEventLogList = (props: RuleEventLogListProps) => {
const {
rule,
localStorageKey = RULE_EVENT_LOG_LIST_STORAGE_KEY,
loadExecutionLogAggregations,
} = props;
const { uiSettings, notifications } = useKibana().services;
// Data grid states
const [logs, setLogs] = useState<IExecutionLog[]>([]);
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
return (
JSON.parse(localStorage.getItem(localStorageKey) ?? 'null') ||
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS
);
});
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [filter, setFilter] = useState<string[]>([]);
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,
})) || []
);
});
// Main cell renderer, renders durations, statuses, etc.
const renderCell = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const { pageIndex, pageSize } = pagination;
const pagedRowIndex = rowIndex - pageIndex * pageSize;
const value = logs?.[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string;
return (
<RuleEventLogListCellRenderer
columnId={columnId as ColumnId}
value={value}
dateFormat={dateFormat}
/>
);
};
// Computed data grid props
const sortingProps = useMemo(
() => ({
onSort: setSortingColumns,
columns: sortingColumns,
}),
[sortingColumns]
);
// 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 () => {
setIsLoading(true);
try {
const result = await loadExecutionLogAggregations({
id: rule.id,
sort: formattedSort as LoadExecutionLogAggregationsProps['sort'],
filter,
dateStart: getParsedDate(dateStart),
dateEnd: getParsedDate(dateEnd),
page: pagination.pageIndex,
perPage: pagination.pageSize,
});
setLogs(result.data);
setPagination({
...pagination,
totalItemCount: 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 paginationProps = useMemo(
() => ({
...pagination,
pageSizeOptions: PAGE_SIZE_OPTION,
onChangeItemsPerPage,
onChangePage,
}),
[pagination, onChangeItemsPerPage, onChangePage]
);
const columnVisibilityProps = useMemo(
() => ({
visibleColumns,
setVisibleColumns,
}),
[visibleColumns, setVisibleColumns]
);
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]
);
useEffect(() => {
loadEventLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortingColumns, dateStart, dateEnd, filter, pagination.pageIndex, pagination.pageSize]);
useEffect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(visibleColumns));
}, [localStorageKey, visibleColumns]);
return (
<div>
<EuiSpacer />
<EuiFlexGroup>
<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 />
{isLoading && (
<EuiProgress size="xs" color="accent" data-test-subj="ruleEventLogListProgressBar" />
)}
<EuiDataGrid
aria-label="rule event log"
data-test-subj="ruleEventLogList"
columns={columns}
rowCount={pagination.totalItemCount}
renderCellValue={renderCell}
columnVisibility={columnVisibilityProps}
sorting={sortingProps}
pagination={paginationProps}
/>
</div>
);
};
export const RuleEventLogListWithApi = withBulkRuleOperations(RuleEventLogList);
// eslint-disable-next-line import/no-default-export
export { RuleEventLogListWithApi as default };

View file

@ -0,0 +1,62 @@
/*
* 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 from 'react';
import moment from 'moment';
import { EuiIcon } from '@elastic/eui';
import { shallow, mount } from 'enzyme';
import {
RuleEventLogListCellRenderer,
DEFAULT_DATE_FORMAT,
} from './rule_event_log_list_cell_renderer';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../../sections/rules_list/components/rule_duration_format';
describe('rule_event_log_list_cell_renderer', () => {
it('renders primitive values correctly', () => {
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="message" value="test" />);
expect(wrapper.text()).toEqual('test');
});
it('renders undefined correctly', () => {
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="message" />);
expect(wrapper.text()).toBeFalsy();
});
it('renders date duration correctly', () => {
const wrapper = shallow(
<RuleEventLogListCellRenderer columnId="execution_duration" value="100000" />
);
expect(wrapper.find(RuleDurationFormat).exists()).toBeTruthy();
expect(wrapper.find(RuleDurationFormat).props().duration).toEqual(100000);
});
it('renders timestamps correctly', () => {
const time = '2022-03-20T07:40:44-07:00';
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="timestamp" value={time} />);
expect(wrapper.text()).toEqual(moment(time).format(DEFAULT_DATE_FORMAT));
});
it('renders alert status correctly', () => {
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="status" value="success" />);
expect(wrapper.find(RuleEventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(RuleEventLogListStatus).props().status).toEqual('success');
});
it('unaccounted status will still render, but with the unknown color', () => {
const wrapper = mount(<RuleEventLogListCellRenderer columnId="status" value="newOutcome" />);
expect(wrapper.find(RuleEventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(RuleEventLogListStatus).text()).toEqual('newOutcome');
expect(wrapper.find(EuiIcon).props().color).toEqual('gray');
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 from 'react';
import moment from 'moment';
import { EcsEventOutcome } from 'kibana/server';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../../sections/rules_list/components/rule_duration_format';
import {
RULE_EXECUTION_LOG_COLUMN_IDS,
RULE_EXECUTION_LOG_DURATION_COLUMNS,
} from '../../../constants';
export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS';
export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number];
interface RuleEventLogListCellRendererProps {
columnId: ColumnId;
value?: string;
dateFormat?: string;
}
export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => {
const { columnId, value, dateFormat = DEFAULT_DATE_FORMAT } = props;
if (typeof value === 'undefined') {
return null;
}
if (columnId === 'status') {
return <RuleEventLogListStatus status={value as EcsEventOutcome} />;
}
if (columnId === 'timestamp') {
return <>{moment(value).format(dateFormat)}</>;
}
if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) {
return <RuleDurationFormat duration={parseInt(value, 10)} />;
}
return <>{value}</>;
};

View file

@ -0,0 +1,42 @@
/*
* 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 from 'react';
import { EuiIcon } from '@elastic/eui';
import { EcsEventOutcome } from 'kibana/server';
interface RuleEventLogListStatusProps {
status: EcsEventOutcome;
}
const statusContainerStyles = {
display: 'flex',
alignItems: 'center',
textTransform: 'capitalize' as const,
};
const iconStyles = {
marginRight: '8px',
};
const STATUS_TO_COLOR: Record<EcsEventOutcome, string> = {
success: 'success',
failure: 'danger',
unknown: 'gray',
};
export const RuleEventLogListStatus = (props: RuleEventLogListStatusProps) => {
const { status } = props;
const color = STATUS_TO_COLOR[status] || 'gray';
return (
<div style={statusContainerStyles}>
<EuiIcon type="dot" color={color} style={iconStyles} />
{status}
</div>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
const onChangeMock = jest.fn();
describe('rule_event_log_list_status_filter', () => {
beforeEach(() => {
onChangeMock.mockReset();
});
it('renders correctly', () => {
const wrapper = mountWithIntl(
<RuleEventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
);
expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy();
expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy();
expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0');
});
it('can open the popover correctly', () => {
const wrapper = mountWithIntl(
<RuleEventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
);
wrapper.find(EuiFilterButton).simulate('click');
const statusItems = wrapper.find(EuiFilterSelectItem);
expect(statusItems.length).toEqual(3);
statusItems.at(0).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith(['success']);
wrapper.setProps({
selectedOptions: ['success'],
});
expect(wrapper.find('.euiNotificationBadge').text()).toEqual('1');
statusItems.at(1).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith(['success', 'failure']);
wrapper.setProps({
selectedOptions: ['success', 'failure'],
});
expect(wrapper.find('.euiNotificationBadge').text()).toEqual('2');
statusItems.at(0).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith(['failure']);
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, { useState, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui';
import { EcsEventOutcome } from 'kibana/server';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
const statusFilters: EcsEventOutcome[] = ['success', 'failure', 'unknown'];
interface RuleEventLogListStatusFilterProps {
selectedOptions: string[];
onChange: (selectedValues: string[]) => void;
}
export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilterProps) => {
const { selectedOptions = [], onChange = () => {} } = props;
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onFilterItemClick = useCallback(
(newOption: string) => () => {
if (selectedOptions.includes(newOption)) {
onChange(selectedOptions.filter((option) => option !== newOption));
return;
}
onChange([...selectedOptions, newOption]);
},
[selectedOptions, onChange]
);
const onClick = useCallback(() => {
setIsPopoverOpen((prevIsOpen) => !prevIsOpen);
}, [setIsPopoverOpen]);
return (
<EuiFilterGroup>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiFilterButton
data-test-subj="ruleEventLogStatusFilterButton"
iconType="arrowDown"
hasActiveFilters={selectedOptions.length > 0}
numActiveFilters={selectedOptions.length}
numFilters={selectedOptions.length}
onClick={onClick}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.eventLogStatusFilterLabel"
defaultMessage="Status"
/>
</EuiFilterButton>
}
>
<>
{statusFilters.map((status) => {
return (
<EuiFilterSelectItem
key={status}
data-test-subj={`ruleEventLogStatusFilter-${status}`}
onClick={onFilterItemClick(status)}
checked={selectedOptions.includes(status) ? 'on' : undefined}
>
<RuleEventLogListStatus status={status} />
</EuiFilterSelectItem>
);
})}
</>
</EuiPopover>
</EuiFilterGroup>
);
};

View file

@ -0,0 +1,21 @@
/*
* 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.
*/
export interface AlertListItemStatus {
label: string;
healthColor: string;
actionGroup?: string;
}
export interface AlertListItem {
alert: string;
status: AlertListItemStatus;
start?: Date;
duration: number;
isMuted: boolean;
sortPriority: number;
}

View file

@ -13,7 +13,7 @@ describe('getIsExperimentalFeatureEnabled', () => {
ExperimentalFeaturesService.init({
experimentalFeatures: {
rulesListDatagrid: true,
rulesDetailLogs: false,
rulesDetailLogs: true,
},
});
@ -23,7 +23,7 @@ describe('getIsExperimentalFeatureEnabled', () => {
result = getIsExperimentalFeatureEnabled('rulesDetailLogs');
expect(result).toEqual(false);
expect(result).toEqual(true);
expect(() => getIsExperimentalFeatureEnabled('doesNotExist' as any)).toThrowError(
'Invalid enable value doesNotExist. Allowed values are: rulesListDatagrid, rulesDetailLogs'

View file

@ -9,6 +9,7 @@ import expect from '@kbn/expect';
import uuid from 'uuid';
import { omit, mapValues, range, flatten } from 'lodash';
import moment from 'moment';
import { asyncForEach } from '@kbn/std';
import { FtrProviderContext } from '../../ftr_provider_context';
import { ObjectRemover } from '../../lib/object_remover';
import { alwaysFiringAlertType } from '../../fixtures/plugins/alerts/server/plugin';
@ -74,7 +75,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
async function createRuleWithActionsAndParams(
testRunUuid: string,
params: Record<string, any> = {}
params: Record<string, any> = {},
overwrites: Record<string, any> = {}
) {
const connectors = await createConnectors(testRunUuid);
return await createAlwaysFiringRule({
@ -88,6 +90,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
},
})),
params,
...overwrites,
});
}
@ -581,6 +584,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
// Get action groups
const { actionGroups } = alwaysFiringAlertType;
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
// Verify content
await testSubjects.existOrFail('alertsList');
@ -679,6 +685,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
// refresh to see rule
await browser.refresh();
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
const alertsList: any[] = await pageObjects.ruleDetailsUI.getAlertsList();
expect(alertsList.filter((a) => a.alert === 'eu/east')).to.eql([
{
@ -691,6 +700,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it('allows the user to mute a specific alert', async () => {
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
// Verify content
await testSubjects.existOrFail('alertsList');
@ -705,6 +717,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it('allows the user to unmute a specific alert', async () => {
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
// Verify content
await testSubjects.existOrFail('alertsList');
@ -725,6 +740,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it('allows the user unmute an inactive alert', async () => {
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
log.debug(`Ensuring eu/east is muted`);
await pageObjects.ruleDetailsUI.ensureAlertMuteState('eu/east', true);
@ -778,6 +796,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const PAGE_SIZE = 10;
it('renders the first page', async () => {
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
// Verify content
await testSubjects.existOrFail('alertsList');
@ -791,6 +812,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it('navigates to the next page', async () => {
// If the tab exists, click on the alert list
await pageObjects.triggersActionsUI.maybeClickOnAlertTab();
// Verify content
await testSubjects.existOrFail('alertsList');
@ -804,5 +828,120 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
});
describe('Execution log', () => {
const testRunUuid = uuid.v4();
let rule: any;
before(async () => {
await pageObjects.common.navigateToApp('triggersActions');
const alerts = [{ id: 'us-central' }];
rule = await createRuleWithActionsAndParams(
testRunUuid,
{
instances: alerts,
},
{
schedule: { interval: '1s' },
throttle: null,
}
);
// refresh to see rule
await browser.refresh();
await pageObjects.header.waitUntilLoadingHasFinished();
// click on first rule
await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(rule.name);
// await first run to complete so we have an initial state
await retry.try(async () => {
const { alerts: alertInstances } = await getAlertSummary(rule.id);
expect(Object.keys(alertInstances).length).to.eql(alerts.length);
});
});
after(async () => {
await objectRemover.removeAll();
});
it('renders the event log list and can filter/sort', async () => {
await browser.refresh();
// Check to see if the experimental is enabled, if not, just return
const tabbedContentExists = await testSubjects.exists('ruleDetailsTabbedContent');
if (!tabbedContentExists) {
return;
}
// Ensure we have some log data to work with
await new Promise((resolve) => setTimeout(resolve, 5000));
const refreshButton = await testSubjects.find('superDatePickerApplyTimeButton');
await refreshButton.click();
// List, date picker, and status picker all exists
await testSubjects.existOrFail('ruleEventLogList');
await testSubjects.existOrFail('ruleEventLogListDatePicker');
await testSubjects.existOrFail('ruleEventLogStatusFilterButton');
let statusFilter = await testSubjects.find('ruleEventLogStatusFilterButton');
let statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(0);
await statusFilter.click();
await testSubjects.click('ruleEventLogStatusFilter-success');
await statusFilter.click();
statusFilter = await testSubjects.find('ruleEventLogStatusFilterButton');
statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(1);
const eventLogList = await find.byCssSelector('.euiDataGridRow');
const rows = await eventLogList.parseDomContent();
expect(rows.length).to.be.greaterThan(0);
await pageObjects.triggersActionsUI.ensureEventLogColumnExists('timestamp');
await pageObjects.triggersActionsUI.ensureEventLogColumnExists('total_search_duration');
const timestampCells = await find.allByCssSelector(
'[data-gridcell-column-id="timestamp"][data-test-subj="dataGridRowCell"]'
);
// The test can be flaky and sometimes we'll get results without dates,
// This is a reasonable compromise as we still validate the good rows
let validTimestamps = 0;
await asyncForEach(timestampCells, async (cell) => {
const text = await cell.getVisibleText();
if (text.toLowerCase() !== 'invalid date') {
if (moment(text).isValid()) {
validTimestamps += 1;
}
}
});
expect(validTimestamps).to.be.greaterThan(0);
// Ensure duration cells are properly formatted
const durationCells = await find.allByCssSelector(
'[data-gridcell-column-id="total_search_duration"][data-test-subj="dataGridRowCell"]'
);
await asyncForEach(durationCells, async (cell) => {
const text = await cell.getVisibleText();
if (text) {
expect(text).to.match(/^N\/A|\d{2,}:\d{2}$/);
}
});
await pageObjects.triggersActionsUI.sortEventLogColumn('timestamp', 'asc');
await pageObjects.triggersActionsUI.sortEventLogColumn('total_search_duration', 'asc');
await testSubjects.existOrFail('dataGridHeaderCellSortingIcon-timestamp');
await testSubjects.existOrFail('dataGridHeaderCellSortingIcon-total_search_duration');
});
});
});
};

View file

@ -141,6 +141,12 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext)
await this.searchAlerts(name);
await find.clickDisplayedByCssSelector(`[data-test-subj="rulesList"] [title="${name}"]`);
},
async maybeClickOnAlertTab() {
if (await testSubjects.exists('ruleDetailsTabbedContent')) {
const alertTab = await testSubjects.find('ruleAlertListTab');
await alertTab.click();
}
},
async changeTabs(tab: 'rulesTab' | 'connectorsTab') {
await testSubjects.click(tab);
},
@ -195,5 +201,32 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext)
expect(title.toLowerCase()).to.eql(expectedStatus.toLowerCase());
});
},
async ensureEventLogColumnExists(columnId: string) {
const columnsButton = await testSubjects.find('dataGridColumnSelectorButton');
await columnsButton.click();
const button = await testSubjects.find(
`dataGridColumnSelectorToggleColumnVisibility-${columnId}`
);
const isChecked = await button.getAttribute('aria-checked');
if (isChecked === 'false') {
await button.click();
}
await columnsButton.click();
},
async sortEventLogColumn(columnId: string, direction: string) {
await testSubjects.click(`dataGridHeaderCell-${columnId}`);
const popover = await testSubjects.find(`dataGridHeaderCellActionGroup-${columnId}`);
const popoverListItems = await popover.findAllByCssSelector('li');
if (direction === 'asc') {
await popoverListItems[1].click();
}
if (direction === 'desc') {
await popoverListItems[2].click();
}
},
};
}