mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
d8323c2246
commit
4235f8157e
29 changed files with 2133 additions and 233 deletions
|
@ -56,7 +56,7 @@ pageLoadAssetSize:
|
|||
telemetry: 51957
|
||||
telemetryManagementSection: 38586
|
||||
transform: 41007
|
||||
triggersActionsUi: 100000
|
||||
triggersActionsUi: 102400
|
||||
upgradeAssistant: 81241
|
||||
uptime: 40825
|
||||
urlForwarding: 32579
|
||||
|
|
40
x-pack/plugins/alerting/common/execution_log_types.ts
Normal file
40
x-pack/plugins/alerting/common/execution_log_types.ts
Normal 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[];
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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\\"}}]",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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 };
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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}</>;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue