diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 34c2285016086..25656a3977fea 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -56,7 +56,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 100000 + triggersActionsUi: 102400 upgradeAssistant: 81241 uptime: 40825 urlForwarding: 32579 diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts new file mode 100644 index 0000000000000..0307985265160 --- /dev/null +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -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[]; +} diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 8a6fec74fb619..732d9061e58da 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -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; diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 22b7d15f72c11..d090e7f649228 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -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: [], diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index c60fce223bea7..ab34158861ad2 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -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 { diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index fa848d56e0113..c3ae062d76523 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -13,7 +13,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; */ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, - rulesDetailLogs: false, + rulesDetailLogs: true, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 77282f834f9fd..0058147ca0d05 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -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', +]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.test.ts index 2ac04ffd6eaaf..7f06baf8698f6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.test.ts @@ -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'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.ts index f5bec63056103..a71d7b8f8efdd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/monitoring_utils.ts @@ -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) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index 75e2bdc8b4a2b..89ede79f4a21d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -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'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.test.ts new file mode 100644 index 0000000000000..c323f0d4fa189 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.test.ts @@ -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\\"}}]", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts new file mode 100644 index 0000000000000..2dceac6dfd7d9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts @@ -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 = ({ 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>( + `${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); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.test.tsx index d0950c0c75fc2..1eefd329d2a4d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.test.tsx @@ -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
; }; @@ -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 ; + }; + + const ExtendedComponent = withBulkRuleOperations(ComponentToExtend); + const component = mount(); + component.find('button').simulate('click'); + + expect(ruleApi.loadExecutionLogAggregations).toHaveBeenCalledTimes(1); + expect(ruleApi.loadExecutionLogAggregations).toHaveBeenCalledWith({ + ...callProps, + http, + }); + }); }); function mockRule(overloads: Partial = {}): Rule { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx index d9bafe5816d69..a9c9dfa72279c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx @@ -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; loadRuleSummary: (id: Rule['id'], numberOfExecutions?: number) => Promise; loadRuleTypes: () => Promise; + loadExecutionLogAggregations: ( + props: LoadExecutionLogAggregationsProps + ) => Promise; getHealth: () => Promise; resolveRule: (id: Rule['id']) => Promise; } @@ -131,6 +137,12 @@ export function withBulkRuleOperations( 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 })} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx index 3f236ea25a19b..b63300a83fcbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx @@ -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; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx index ac103f113a8f1..737501f444300 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx @@ -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).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( - - ) - .find(EuiBasicTable) - .prop('items') - ).toEqual(rules); + const wrapper = mountWithIntl( + + ); + + 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 = { @@ -108,27 +122,31 @@ describe('rules', () => { muted: false, }, }; - expect( - shallow( - - ) - .find(EuiBasicTable) - .prop('items') - ).toEqual([ + + const wrapper = mountWithIntl( + + ); + + 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( - - ) - .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).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( + + ); + + 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 { return { id: uuid.v4(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx index 1a08f12c11743..c3eb699cc0c90 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx @@ -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; -export const alertsTableColumns = ( - onMuteAction: (alert: AlertListItem) => Promise, - 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 ( - - {value} - - ); - }, - }, - { - field: 'status', - name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', { - defaultMessage: 'Status', - }), - width: '15%', - render: (value: AlertListItemStatus) => { - return ( - - {value.label} - {value.actionGroup ? ` (${value.actionGroup})` : ``} - - ); - }, - 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 ( - 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({ - 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 ; + } + return renderRuleAlertList(); + }; + return ( <> @@ -277,50 +224,12 @@ export function RuleComponent({ name="alertsDurationEpoch" value={durationEpoch} /> - { - 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' } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx new file mode 100644 index 0000000000000..5756edd360393 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx @@ -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, + 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 ( + + {value} + + ); + }, + }, + { + field: 'status', + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', { + defaultMessage: 'Status', + }), + width: '15%', + render: (value: AlertListItemStatus) => { + return ( + + {value.label} + {value.actionGroup ? ` (${value.actionGroup})` : ``} + + ); + }, + 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 ( + await onMuteAction(alert)} + alert={alert} + /> + ); + }, + sortable: false, + 'data-test-subj': 'alertsTableCell-actions', + }, +]; + +interface RuleAlertListProps { + items: AlertListItem[]; + readOnly: boolean; + onMuteAction: (alert: AlertListItem) => Promise; +} + +const getRowProps = () => ({ + 'data-test-subj': 'alert-row', +}); + +const getCellProps = () => ({ + 'data-test-subj': 'cell', +}); + +function getPage(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({ + index: 0, + size: DEFAULT_SEARCH_PAGE_SIZE, + }); + + const pageOfAlerts = getPage(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 ( + + 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 }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx new file mode 100644 index 0000000000000..008e19bcb99bf --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx @@ -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; +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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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']); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx new file mode 100644 index 0000000000000..9a6814d1dd9c4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx @@ -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; + +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([]); + const [visibleColumns, setVisibleColumns] = useState(() => { + return ( + JSON.parse(localStorage.getItem(localStorageKey) ?? 'null') || + RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS + ); + }); + const [sortingColumns, setSortingColumns] = useState([]); + const [filter, setFilter] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + totalItemCount: 0, + }); + + // Date related states + const [isLoading, setIsLoading] = useState(false); + const [dateStart, setDateStart] = useState('now-24h'); + const [dateEnd, setDateEnd] = useState('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 ( + + ); + }; + + // 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 ( +
+ + + + + + + + + + + {isLoading && ( + + )} + +
+ ); +}; + +export const RuleEventLogListWithApi = withBulkRuleOperations(RuleEventLogList); + +// eslint-disable-next-line import/no-default-export +export { RuleEventLogListWithApi as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx new file mode 100644 index 0000000000000..f973815dbd586 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx @@ -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(); + + expect(wrapper.text()).toEqual('test'); + }); + + it('renders undefined correctly', () => { + const wrapper = shallow(); + + expect(wrapper.text()).toBeFalsy(); + }); + + it('renders date duration correctly', () => { + const wrapper = shallow( + + ); + + 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(); + + expect(wrapper.text()).toEqual(moment(time).format(DEFAULT_DATE_FORMAT)); + }); + + it('renders alert status correctly', () => { + const wrapper = shallow(); + + 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(); + + expect(wrapper.find(RuleEventLogListStatus).exists()).toBeTruthy(); + expect(wrapper.find(RuleEventLogListStatus).text()).toEqual('newOutcome'); + expect(wrapper.find(EuiIcon).props().color).toEqual('gray'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx new file mode 100644 index 0000000000000..ea502f6c5ebbc --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx @@ -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 ; + } + + if (columnId === 'timestamp') { + return <>{moment(value).format(dateFormat)}; + } + + if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) { + return ; + } + + return <>{value}; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status.tsx new file mode 100644 index 0000000000000..8b50ec1469698 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status.tsx @@ -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 = { + success: 'success', + failure: 'danger', + unknown: 'gray', +}; + +export const RuleEventLogListStatus = (props: RuleEventLogListStatusProps) => { + const { status } = props; + const color = STATUS_TO_COLOR[status] || 'gray'; + + return ( +
+ + {status} +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.test.tsx new file mode 100644 index 0000000000000..bb7f87b59aa5b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.test.tsx @@ -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( + + ); + + 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( + + ); + + 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']); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.tsx new file mode 100644 index 0000000000000..85adfe90def44 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_status_filter.tsx @@ -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(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 ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedOptions.length} + numFilters={selectedOptions.length} + onClick={onClick} + > + + + } + > + <> + {statusFilters.map((status) => { + return ( + + + + ); + })} + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts new file mode 100644 index 0000000000000..0a4b706c8676e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts @@ -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; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx index 23d77b6e07a9b..0b865eb5b61be 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx @@ -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' diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 74595e812f42a..22c98b189a590 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -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 = {} + params: Record = {}, + overwrites: Record = {} ) { 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'); + }); + }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 7379a5ad1329c..e346971b48ea4 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -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(); + } + }, }; }