From ba3dc271b29668b6f5ba863d132d669bf9c5da52 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 4 May 2022 20:54:11 -0600 Subject: [PATCH] [8.2] [Security Solution][Detections] Rule Execution Log Feedback and Fixes Part Deux (#130072) (#131574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security Solution][Detections] Rule Execution Log Feedback and Fixes Part Deux (#130072) ## Summary Addresses feedback and fixes identified in https://github.com/elastic/kibana/pull/126215 & https://github.com/elastic/kibana/pull/129003 ##### Feedback addressed includes: * Adds toast for restoring global query state after performing `view alerts for execution` action

* Updates global SuperDatePicker to daterange of execution (+/- day) for `view alerts for execution` action (and clear all other filters) * See above gif * Remove redundant `RuleExecutionStatusType` (https://github.com/elastic/kibana/pull/129003#discussion_r842924704) * Persist table state (DatePicker/StatusFilter/SortField/SortOrder/Pagination) when navigating to other tabs on the same page

* Fix duration hours bug (`7 hours (25033167ms)` as `06:417:13:000`)

* Support `disabled rule` platform error (https://github.com/elastic/kibana/pull/126215#discussion_r834364979) * Updated `getAggregateExecutionEvents` to fallback to platform status from `event.outcome` if `security_status` is empty, and also falls back to `error.message` is `security_message` is empty. This also now queries for corresponding `event.outcome` if filter is provided so that platform-only events can still be displayed when filtering.

* Verify StatusFilter issue https://github.com/elastic/kibana/pull/126215#issuecomment-1080976155 * Unable to reproduce, I believe the query updates around first querying for status may've fixed this? * Provide helpful defaults for `to`/`from` and support datemath strings again (https://github.com/elastic/kibana/pull/129003#discussion_r843091926) * Created enhancement for this here: https://github.com/elastic/kibana/issues/131095 * Adds UI Unit tests for RuleExecutionLog Table * Finalize API Integration tests for gap remediation events * Test methods developed for injecting arbitrary execution events while still working with event-log RBAC. See last [API integration test](https://github.com/elastic/kibana/blob/22cc0c8dbd2a1300675caf4c6d471d211ed44858/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_rule_execution_events.ts#L121-L166) for technique. This can further be used to inject many execution events and expand tests around pagination, sorting, filters, etc. * Fixes `gap_duration`'s of `1-499`ms showing up as `-` instead of `0` * Fixes restore filters action to restore either absolute or relative datepicker as it originally was * Resolves https://github.com/elastic/kibana/issues/130946 * Adds `min-height` to tab container * Removes scroll-pane from ExceptionsViewer to match Alerts/Execution Log --- ##### Remaining follow-ups: None! 🎉 ### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [X] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [X] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) (cherry picked from commit 683463ea43db9fc7ba2be43c56d2d059018e90c5) # Conflicts: # x-pack/plugins/security_solution/cypress/tasks/alerts.ts # x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_columns.tsx # x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts # x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.test.ts # x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.ts # x-pack/test/detection_engine_api_integration/utils/index_event_log_execution_events.ts * Fixing import --- .../schemas/common/rule_monitoring.ts | 6 +- .../get_rule_execution_events_schema.ts | 16 +- .../security_solution/cypress/tasks/alerts.ts | 2 + .../viewer/exceptions_viewer_items.tsx | 9 +- .../visualization_actions/index.test.tsx | 1 + .../events/last_event_time/index.test.ts | 1 + .../containers/sourcerer/index.test.tsx | 1 + .../common/hooks/use_app_toasts.mock.ts | 1 + .../common/hooks/use_app_toasts.test.ts | 3 + .../public/common/hooks/use_app_toasts.ts | 7 +- .../common/lib/kibana/kibana_react.mock.ts | 4 + .../alerts_histogram_panel/index.test.tsx | 1 + .../detection_engine/rules/__mocks__/api.ts | 2 +- .../detection_engine/rules/api.test.ts | 4 +- .../containers/detection_engine/rules/api.ts | 21 +- .../containers/detection_engine/rules/mock.ts | 168 +++++++++ .../rules/use_pre_packaged_rules.test.tsx | 1 + .../rules/use_rule_execution_events.test.tsx | 4 +- .../rules/use_rule_execution_events.tsx | 14 +- .../detection_engine.test.tsx | 1 + .../__mocks__/rule_details_context.tsx | 62 ++++ .../execution_log_table.test.tsx.snap | 140 -------- .../rule_duration_format.test.tsx.snap | 9 - .../execution_log_columns.tsx | 8 +- .../execution_log_search_bar.tsx | 11 +- .../execution_log_table.test.tsx | 95 ++--- .../execution_log_table.tsx | 243 ++++++++++--- .../rule_duration_format.test.tsx | 81 ++++- .../rule_duration_format.tsx | 45 ++- .../execution_log_table/translations.ts | 21 ++ .../detection_engine/rules/details/index.tsx | 339 +++++++++--------- .../rules/details/rule_details_context.tsx | 216 +++++++++++ .../public/network/pages/network.test.tsx | 1 + .../timelines/containers/index.test.tsx | 1 + .../routes/__mocks__/request_responses.ts | 4 +- .../get_rule_execution_events_route.test.ts | 2 - .../client_for_routes/client_interface.ts | 4 +- .../event_log/event_log_reader.ts | 10 +- .../index.test.ts | 73 +++- .../get_execution_event_aggregation/index.ts | 60 +++- .../tests/get_rule_execution_events.ts | 79 +--- .../tests/template_data/execution_events.ts | 197 ++++++++-- .../detection_engine_api_integration/utils.ts | 3 +- 43 files changed, 1329 insertions(+), 642 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/__mocks__/rule_details_context.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/execution_log_table.test.tsx.snap delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/rule_duration_format.test.tsx.snap create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/rule_details_context.tsx diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_monitoring.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_monitoring.ts index f73e60487a4a8..af005d1b60a8f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_monitoring.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_monitoring.ts @@ -53,8 +53,6 @@ export enum RuleExecutionStatus { export const ruleExecutionStatus = enumeration('RuleExecutionStatus', RuleExecutionStatus); -export type RuleExecutionStatusType = t.TypeOf; - export const ruleExecutionStatusOrder = PositiveInteger; export type RuleExecutionStatusOrder = t.TypeOf; @@ -130,7 +128,7 @@ export const aggregateRuleExecutionEvent = t.type({ timed_out: t.boolean, indexing_duration_ms: t.number, search_duration_ms: t.number, - gap_duration_ms: t.number, + gap_duration_s: t.number, security_status: t.string, security_message: t.string, }); @@ -140,7 +138,7 @@ export type AggregateRuleExecutionEvent = t.TypeOf( 'DefaultStatusFiltersStringArray', t.array(ruleExecutionStatus).is, - (input, context): Either => { + (input, context): Either => { if (input == null) { return t.success([]); } else if (typeof input === 'string') { - return t.array(ruleExecutionStatus).validate(input.split(','), context); + if (input === '') { + return t.success([]); + } else { + return t.array(ruleExecutionStatus).validate(input.split(','), context); + } } else { return t.array(ruleExecutionStatus).validate(input, context); } diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 219f308b01e9c..3c4d434b1ec3f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -64,6 +64,8 @@ export const closeAlerts = () => { }; export const expandFirstAlertActions = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).should('be.visible'); + cy.get(TIMELINE_CONTEXT_MENU_BTN).find('svg').should('have.class', 'euiIcon-isLoaded'); cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx index 5331b2376fd9f..30b5b3e4d1339 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx @@ -23,11 +23,6 @@ const MyFlexItem = styled(EuiFlexItem)` } `; -const MyExceptionsContainer = styled(EuiFlexGroup)` - height: 600px; - overflow: hidden; -`; - const MyExceptionItemContainer = styled(EuiFlexGroup)` margin: ${({ theme }) => `0 ${theme.eui.euiSize} ${theme.eui.euiSize} 0`}; `; @@ -55,7 +50,7 @@ const ExceptionsViewerItemsComponent: React.FC = ({ onEditExceptionItem, disableActions, }): JSX.Element => ( - + {showEmpty || showNoResults || isInitLoading ? ( = ({ )} - + ); ExceptionsViewerItemsComponent.displayName = 'ExceptionsViewerItemsComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx index 00ae0873472e9..47dbe4ad31a4c 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx @@ -95,6 +95,7 @@ describe('VisualizationActions', () => { addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }, }, http: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts index 21791952fec06..dd638c7a613f5 100644 --- a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts @@ -47,6 +47,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }), })); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index 1d02d608c0e92..a949478593466 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -60,6 +60,7 @@ jest.mock('../../lib/kibana', () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: mockAddWarning, + remove: jest.fn(), }), useKibana: () => ({ services: { diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts index c0bb52b20c534..ae3783e82cdbf 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts @@ -11,6 +11,7 @@ const createAppToastsMock = (): jest.Mocked => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), api: { get$: jest.fn(), add: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index 786f25d8c6aba..34c736ede755e 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -30,15 +30,18 @@ describe('useAppToasts', () => { let addErrorMock: jest.Mock; let addSuccessMock: jest.Mock; let addWarningMock: jest.Mock; + let removeMock: jest.Mock; beforeEach(() => { addErrorMock = jest.fn(); addSuccessMock = jest.fn(); addWarningMock = jest.fn(); + removeMock = jest.fn(); (useToasts as jest.Mock).mockImplementation(() => ({ addError: addErrorMock, addSuccess: addSuccessMock, addWarning: addWarningMock, + remove: removeMock, })); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index b0719960ff2c8..537c154014744 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -19,7 +19,7 @@ import { IEsError, isEsError } from '../../../../../../src/plugins/data/public'; import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/core/public'; import { useToasts } from '../lib/kibana'; -export type UseAppToasts = Pick & { +export type UseAppToasts = Pick & { api: ToastsStart; addError: (error: unknown, options: ErrorToastOptions) => Toast; }; @@ -36,6 +36,7 @@ export const useAppToasts = (): UseAppToasts => { const addError = useRef(toasts.addError.bind(toasts)).current; const addSuccess = useRef(toasts.addSuccess.bind(toasts)).current; const addWarning = useRef(toasts.addWarning.bind(toasts)).current; + const remove = useRef(toasts.remove.bind(toasts)).current; const _addError = useCallback( (error: unknown, options: ErrorToastOptions) => { @@ -46,8 +47,8 @@ export const useAppToasts = (): UseAppToasts => { ); return useMemo( - () => ({ api: toasts, addError: _addError, addSuccess, addWarning }), - [_addError, addSuccess, addWarning, toasts] + () => ({ api: toasts, addError: _addError, addSuccess, addWarning, remove }), + [_addError, addSuccess, addWarning, remove, toasts] ); }; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index b683f4bd1375a..5adb2e12198b6 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -158,6 +158,10 @@ export const createStartServicesMock = ( theme: { theme$: themeServiceMock.createTheme$(), }, + timelines: { + getLastUpdated: jest.fn(), + getFieldBrowser: jest.fn(), + }, } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 3135e2e173793..1a95a17a9f11d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -55,6 +55,7 @@ jest.mock('../../../../common/lib/kibana/kibana_react', () => { addWarning: jest.fn(), addError: jest.fn(), addSuccess: jest.fn(), + remove: jest.fn(), }, }, }, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index d93d667f5fbca..59f79b294d7fa 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -78,7 +78,7 @@ export const fetchRuleExecutionEvents = async ({ duration_ms: 3866, es_search_duration_ms: 1236, execution_uuid: '88d15095-7937-462c-8f21-9763e1387cad', - gap_duration_ms: 0, + gap_duration_s: 0, indexing_duration_ms: 95, message: "rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'", diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 3c534ca7294a5..b2d857adcc9e3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -630,7 +630,7 @@ describe('Detections Rules API', () => { start: '2001-01-01T17:00:00.000Z', end: '2001-01-02T17:00:00.000Z', queryText: '', - statusFilters: '', + statusFilters: [], signal: abortCtrl.signal, }); @@ -659,7 +659,7 @@ describe('Detections Rules API', () => { start: 'now-30', end: 'now', queryText: '', - statusFilters: '', + statusFilters: [], signal: abortCtrl.signal, }); expect(response).toEqual(responseMock); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 7223e11eed76d..8866b3a49c98e 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { camelCase } from 'lodash'; import dateMath from '@kbn/datemath'; import { HttpStart } from 'src/core/public'; @@ -18,7 +19,11 @@ import { DETECTION_ENGINE_RULES_PREVIEW, detectionEngineRuleExecutionEventsUrl, } from '../../../../../common/constants'; -import { BulkAction } from '../../../../../common/detection_engine/schemas/common'; +import { + AggregateRuleExecutionEvent, + BulkAction, + RuleExecutionStatus, +} from '../../../../../common/detection_engine/schemas/common'; import { FullResponseSchema, PreviewResponse, @@ -320,11 +325,11 @@ export const exportRules = async ({ * @param start Start daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now-30`) * @param end End daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now/w`) * @param queryText search string in querystring format (e.g. `event.duration > 1000 OR kibana.alert.rule.execution.metrics.execution_gap_duration_s > 100`) - * @param statusFilters comma separated string of `statusFilters` (e.g. `succeeded,failed,partial failure`) + * @param statusFilters RuleExecutionStatus[] array of `statusFilters` (e.g. `succeeded,failed,partial failure`) * @param page current page to fetch * @param perPage number of results to fetch per page - * @param sortField field to sort by - * @param sortOrder what order to sort by (e.g. `asc` or `desc`) + * @param sortField keyof AggregateRuleExecutionEvent field to sort by + * @param sortOrder SortOrder what order to sort by (e.g. `asc` or `desc`) * @param signal AbortSignal Optional signal for cancelling the request * * @throws An error if response is not OK @@ -345,11 +350,11 @@ export const fetchRuleExecutionEvents = async ({ start: string; end: string; queryText?: string; - statusFilters?: string; + statusFilters?: RuleExecutionStatus[]; page?: number; perPage?: number; - sortField?: string; - sortOrder?: string; + sortField?: keyof AggregateRuleExecutionEvent; + sortOrder?: SortOrder; signal?: AbortSignal; }): Promise => { const url = detectionEngineRuleExecutionEventsUrl(ruleId); @@ -361,7 +366,7 @@ export const fetchRuleExecutionEvents = async ({ start: startDate?.utc().toISOString(), end: endDate?.utc().toISOString(), query_text: queryText, - status_filters: statusFilters, + status_filters: statusFilters?.sort()?.join(','), page, per_page: perPage, sort_field: sortField, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index 533ab6138cb09..8c1737a4519a7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { GetAggregateRuleExecutionEventsResponse } from '../../../../../common/detection_engine/schemas/response'; import { FetchRulesResponse, Rule } from './types'; export const savedRuleMock: Rule = { @@ -126,3 +127,170 @@ export const rulesMock: FetchRulesResponse = { }, ], }; + +export const ruleExecutionEventsMock: GetAggregateRuleExecutionEventsResponse = { + events: [ + { + execution_uuid: 'dc45a63c-4872-4964-a2d0-bddd8b2e634d', + timestamp: '2022-04-28T21:19:08.047Z', + duration_ms: 3, + status: 'failure', + message: 'siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: execution failed', + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + schedule_delay_ms: 2169, + timed_out: false, + indexing_duration_ms: 0, + search_duration_ms: 0, + gap_duration_s: 0, + security_status: 'failed', + security_message: 'Rule failed to execute because rule ran after it was disabled.', + }, + { + execution_uuid: '0fde9271-05d0-4bfb-8ff8-815756d28350', + timestamp: '2022-04-28T21:19:04.973Z', + duration_ms: 1446, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + schedule_delay_ms: 2089, + timed_out: false, + indexing_duration_ms: 0, + search_duration_ms: 2, + gap_duration_s: 0, + security_status: 'succeeded', + security_message: 'succeeded', + }, + { + execution_uuid: '5daaa259-ded8-4a52-853e-1e7652d325d5', + timestamp: '2022-04-28T21:19:01.976Z', + duration_ms: 1395, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 1, + schedule_delay_ms: 2637, + timed_out: false, + indexing_duration_ms: 0, + search_duration_ms: 3, + gap_duration_s: 0, + security_status: 'succeeded', + security_message: 'succeeded', + }, + { + execution_uuid: 'c7223e1c-4264-4a27-8697-0d720243fafc', + timestamp: '2022-04-28T21:18:58.431Z', + duration_ms: 1815, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 1, + schedule_delay_ms: -255429, + timed_out: false, + indexing_duration_ms: 0, + search_duration_ms: 3, + gap_duration_s: 0, + security_status: 'succeeded', + security_message: 'succeeded', + }, + { + execution_uuid: '1f6ba0c1-cc36-4f45-b919-7790b8a8d670', + timestamp: '2022-04-28T21:18:13.954Z', + duration_ms: 2055, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + schedule_delay_ms: 2027, + timed_out: false, + indexing_duration_ms: 0, + search_duration_ms: 0, + gap_duration_s: 0, + security_status: 'partial failure', + security_message: + 'Check privileges failed to execute ResponseError: index_not_found_exception: [index_not_found_exception] Reason: no such index [yup] name: "Click here for hot fresh alerts!" id: "a6e61cf0-c737-11ec-9e32-e14913ffdd2d" rule id: "34946b12-88d1-49ef-82b7-9cad45972030" execution id: "1f6ba0c1-cc36-4f45-b919-7790b8a8d670" space ID: "default"', + }, + { + execution_uuid: 'b0f65d64-b229-432b-9d39-f4385a7f9368', + timestamp: '2022-04-28T21:15:43.086Z', + duration_ms: 1205, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 672, + schedule_delay_ms: 3086, + timed_out: false, + indexing_duration_ms: 140, + search_duration_ms: 684, + gap_duration_s: 0, + security_status: 'succeeded', + security_message: 'succeeded', + }, + { + execution_uuid: '7bfd25b9-c0d8-44b1-982c-485169466a8e', + timestamp: '2022-04-28T21:10:40.135Z', + duration_ms: 6321, + status: 'success', + message: + "rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 930, + schedule_delay_ms: 1222, + timed_out: false, + indexing_duration_ms: 2103, + search_duration_ms: 946, + gap_duration_s: 0, + security_status: 'succeeded', + security_message: 'succeeded', + }, + ], + total: 7, +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx index 6596cefef4f08..dfeaca617ed24 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx @@ -18,6 +18,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }), })); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.test.tsx index 2a4efb7f69491..5fba7dd7a2ed2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.test.tsx @@ -51,7 +51,7 @@ describe('useRuleExecutionEvents', () => { start: 'now-30', end: 'now', queryText: '', - statusFilters: '', + statusFilters: [], }), { wrapper: createReactQueryWrapper(), @@ -92,7 +92,7 @@ describe('useRuleExecutionEvents', () => { duration_ms: 3866, es_search_duration_ms: 1236, execution_uuid: '88d15095-7937-462c-8f21-9763e1387cad', - gap_duration_ms: 0, + gap_duration_s: 0, indexing_duration_ms: 95, message: "rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'", diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.tsx index f2e72858cf392..e18d1f6c2ce5c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_execution_events.tsx @@ -5,22 +5,27 @@ * 2.0. */ +import { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { useQuery } from 'react-query'; +import { + AggregateRuleExecutionEvent, + RuleExecutionStatus, +} from '../../../../../common/detection_engine/schemas/common'; import { GetAggregateRuleExecutionEventsResponse } from '../../../../../common/detection_engine/schemas/response'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { fetchRuleExecutionEvents } from './api'; import * as i18n from './translations'; -interface UseRuleExecutionEventsArgs { +export interface UseRuleExecutionEventsArgs { ruleId: string; start: string; end: string; queryText?: string; - statusFilters?: string; + statusFilters?: RuleExecutionStatus[]; page?: number; perPage?: number; - sortField?: string; - sortOrder?: string; + sortField?: keyof AggregateRuleExecutionEvent; + sortOrder?: SortOrder; } export const useRuleExecutionEvents = ({ @@ -66,6 +71,7 @@ export const useRuleExecutionEvents = ({ }); }, { + keepPreviousData: true, onError: (e) => { addError(e, { title: i18n.RULE_EXECUTION_EVENTS_FETCH_FAILURE }); }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 1f3cacbefcc3b..3375f408de2ff 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -98,6 +98,7 @@ jest.mock('../../../common/lib/kibana', () => { addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }), }; }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/__mocks__/rule_details_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/__mocks__/rule_details_context.tsx new file mode 100644 index 0000000000000..257dc8ec512a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/__mocks__/rule_details_context.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 { RuleDetailsContextType } from '../rule_details_context'; +import React from 'react'; + +export const useRuleDetailsContextMock = { + create: (): jest.Mocked => ({ + executionLogs: { + state: { + superDatePicker: { + recentlyUsedRanges: [], + refreshInterval: 1000, + isPaused: true, + start: 'now-24h', + end: 'now', + }, + queryText: '', + statusFilters: [], + showMetricColumns: true, + pagination: { + pageIndex: 1, + pageSize: 5, + }, + sort: { + sortField: 'timestamp', + sortDirection: 'desc', + }, + }, + actions: { + setEnd: jest.fn(), + setIsPaused: jest.fn(), + setPageIndex: jest.fn(), + setPageSize: jest.fn(), + setQueryText: jest.fn(), + setRecentlyUsedRanges: jest.fn(), + setRefreshInterval: jest.fn(), + setShowMetricColumns: jest.fn(), + setSortDirection: jest.fn(), + setSortField: jest.fn(), + setStart: jest.fn(), + setStatusFilters: jest.fn(), + }, + }, + }), +}; + +export const useRuleDetailsContext = jest + .fn, []>() + .mockImplementation(useRuleDetailsContextMock.create); + +export const useRuleDetailsContextOptional = jest + .fn, []>() + .mockImplementation(useRuleDetailsContextMock.create); + +export const RulesTableContextProvider = jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => <>{children}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/execution_log_table.test.tsx.snap b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/execution_log_table.test.tsx.snap deleted file mode 100644 index f93cce70172dc..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/execution_log_table.test.tsx.snap +++ /dev/null @@ -1,140 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ExecutionLogTable snapshots renders correctly against snapshot 1`] = ` - - - - - - - - - - - - - - - Showing 0 rule executions - - - - - - - - - - - , - "render": [Function], - "sortable": false, - "truncateText": false, - "width": "10%", - }, - Object { - "field": "timestamp", - "name": , - "render": [Function], - "sortable": true, - "truncateText": false, - "width": "15%", - }, - Object { - "field": "duration_ms", - "name": , - "render": [Function], - "sortable": true, - "truncateText": false, - "width": "10%", - }, - Object { - "field": "security_message", - "name": , - "render": [Function], - "sortable": false, - "truncateText": false, - "width": "35%", - }, - ] - } - items={Array []} - noItemsMessage={ - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 5, - "pageSizeOptions": Array [ - 5, - 10, - 25, - 50, - ], - "totalItemCount": 0, - } - } - responsive={true} - sorting={ - Object { - "sort": Object { - "direction": "desc", - "field": "timestamp", - }, - } - } - tableLayout="fixed" - /> - -`; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/rule_duration_format.test.tsx.snap b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/rule_duration_format.test.tsx.snap deleted file mode 100644 index ddb59cf52a890..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/__snapshots__/rule_duration_format.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RuleDurationFormat snapshots renders correctly against snapshot 1`] = ` - - 00:00:00:000 - -`; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_columns.tsx index 5242b6dbe0313..65605a0a76905 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_columns.tsx @@ -19,9 +19,9 @@ import { FormattedDate } from '../../../../../../common/components/formatted_dat import { getStatusColor } from '../../../../../components/rules/rule_execution_status/utils'; import { PopoverTooltip } from '../../all/popover_tooltip'; import { TableHeaderTooltipCell } from '../../all/table_header_tooltip_cell'; +import { RuleDurationFormat } from './rule_duration_format'; import * as i18n from './translations'; -import { RuleDurationFormat } from './rule_duration_format'; export const EXECUTION_LOG_COLUMNS: Array> = [ { @@ -32,7 +32,7 @@ export const EXECUTION_LOG_COLUMNS: Array ), field: 'security_status', - render: (value: RuleExecutionStatus, data) => + render: (value: RuleExecutionStatus) => value ? ( {capitalize(value)} ) : ( @@ -89,7 +89,7 @@ export const GET_EXECUTION_LOG_METRICS_COLUMNS = ( docLinks: DocLinksStart ): Array> => [ { - field: 'gap_duration_ms', + field: 'gap_duration_s', name: ( ), render: (value: number) => ( - <>{value ? : getEmptyValue()} + <>{value ? : getEmptyValue()} ), sortable: true, truncateText: false, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_search_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_search_bar.tsx index b8c2d82ab324e..74804b7c6b557 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_search_bar.tsx @@ -56,20 +56,23 @@ const statusFilters = statuses.map((status) => ({ interface ExecutionLogTableSearchProps { onlyShowFilters: true; onSearch: (queryText: string) => void; - onStatusFilterChange: (statusFilters: string[]) => void; + onStatusFilterChange: (statusFilters: RuleExecutionStatus[]) => void; + defaultSelectedStatusFilters?: RuleExecutionStatus[]; } /** * SearchBar + StatusFilters component to be used with the Rule Execution Log table - * NOTE: This component is currently not shown in the UI as custom search queries + * NOTE: The SearchBar component is currently not shown in the UI as custom search queries * are not yet fully supported by the Rule Execution Log aggregation API since * certain queries could result in missing data or inclusion of wrong events. * Please see this comment for history/details: https://github.com/elastic/kibana/pull/127339/files#r825240516 */ export const ExecutionLogSearchBar = React.memo( - ({ onlyShowFilters, onSearch, onStatusFilterChange }) => { + ({ onlyShowFilters, onSearch, onStatusFilterChange, defaultSelectedStatusFilters = [] }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [selectedFilters, setSelectedFilters] = useState([]); + const [selectedFilters, setSelectedFilters] = useState( + defaultSelectedStatusFilters + ); const onSearchCallback = useCallback( (queryText: string) => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.test.tsx index 81619d01934ba..608853f004eb9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.test.tsx @@ -5,83 +5,21 @@ * 2.0. */ +import { ruleExecutionEventsMock } from '../../../../../containers/detection_engine/rules/mock'; +import { render, screen } from '@testing-library/react'; +import { TestProviders } from '../../../../../../common/mock'; +import { useRuleDetailsContextMock } from '../__mocks__/rule_details_context'; import React from 'react'; -import { shallow } from 'enzyme'; import { noop } from 'lodash/fp'; +import { useRuleExecutionEvents } from '../../../../../containers/detection_engine/rules'; import { useSourcererDataView } from '../../../../../../common/containers/sourcerer'; +import { useRuleDetailsContext } from '../rule_details_context'; import { ExecutionLogTable } from './execution_log_table'; -jest.mock('../../../../../containers/detection_engine/rules', () => { - const original = jest.requireActual('../../../../../containers/detection_engine/rules'); - return { - ...original, - useRuleExecutionEvents: jest.fn().mockReturnValue({ - loading: true, - setQuery: () => undefined, - data: null, - response: '', - request: '', - refetch: null, - }), - }; -}); - jest.mock('../../../../../../common/containers/sourcerer'); - -jest.mock('../../../../../../common/hooks/use_app_toasts', () => { - const original = jest.requireActual('../../../../../../common/hooks/use_app_toasts'); - - return { - ...original, - useAppToasts: () => ({ - addSuccess: jest.fn(), - addError: jest.fn(), - }), - }; -}); - -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - return { - ...original, - useDispatch: () => jest.fn(), - useSelector: () => jest.fn(), - }; -}); - -jest.mock('../../../../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../../../../common/lib/kibana'); - - return { - ...original, - useUiSetting$: jest.fn().mockReturnValue([]), - useKibana: () => ({ - services: { - data: { - query: { - filterManager: jest.fn().mockReturnValue({}), - }, - }, - docLinks: { - links: { - siem: { - troubleshootGaps: 'link', - }, - }, - }, - storage: { - get: jest.fn(), - set: jest.fn(), - }, - timelines: { - getLastUpdated: jest.fn(), - getFieldBrowser: jest.fn(), - }, - }, - }), - }; -}); +jest.mock('../../../../../containers/detection_engine/rules'); +jest.mock('../rule_details_context'); const mockUseSourcererDataView = useSourcererDataView as jest.Mock; mockUseSourcererDataView.mockReturnValue({ @@ -92,13 +30,20 @@ mockUseSourcererDataView.mockReturnValue({ loading: false, }); -// TODO: Replace snapshot test with base test cases +const mockUseRuleExecutionEvents = useRuleExecutionEvents as jest.Mock; +mockUseRuleExecutionEvents.mockReturnValue({ + data: ruleExecutionEventsMock, + isLoading: false, + isFetching: false, +}); describe('ExecutionLogTable', () => { - describe('snapshots', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + test('Shows total events returned', () => { + const ruleDetailsContext = useRuleDetailsContextMock.create(); + (useRuleDetailsContext as jest.Mock).mockReturnValue(ruleDetailsContext); + render(, { + wrapper: TestProviders, }); + expect(screen.getByTestId('executionsShowing')).toHaveTextContent('Showing 7 rule executions'); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.tsx index 31fc446fa2bb7..d90cb0d3b84ea 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/execution_log_table.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import React, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment'; +import React, { useCallback, useMemo, useRef } from 'react'; import { EuiTextColor, EuiFlexGroup, @@ -20,11 +20,17 @@ import { EuiSpacer, EuiSwitch, EuiBasicTable, + EuiButton, } from '@elastic/eui'; -import { buildFilter, FILTERS } from '@kbn/es-query'; +import { buildFilter, Filter, FILTERS, Query } from '@kbn/es-query'; import { MAX_EXECUTION_EVENTS_DISPLAYED } from '@kbn/securitysolution-rules'; +import { mountReactNode } from '../../../../../../../../../../src/core/public/utils'; +import { RuleDetailTabs } from '..'; import { RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY } from '../../../../../../../common/constants'; -import { AggregateRuleExecutionEvent } from '../../../../../../../common/detection_engine/schemas/common'; +import { + AggregateRuleExecutionEvent, + RuleExecutionStatus, +} from '../../../../../../../common/detection_engine/schemas/common'; import { UtilityBar, @@ -34,9 +40,23 @@ import { } from '../../../../../../common/components/utility_bar'; import { useSourcererDataView } from '../../../../../../common/containers/sourcerer'; import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts'; +import { useDeepEqualSelector } from '../../../../../../common/hooks/use_selector'; import { useKibana } from '../../../../../../common/lib/kibana'; +import { inputsSelectors } from '../../../../../../common/store'; +import { + setAbsoluteRangeDatePicker, + setFilterQuery, + setRelativeRangeDatePicker, +} from '../../../../../../common/store/inputs/actions'; +import { + AbsoluteTimeRange, + isAbsoluteTimeRange, + isRelativeTimeRange, + RelativeTimeRange, +} from '../../../../../../common/store/inputs/model'; import { SourcererScopeName } from '../../../../../../common/store/sourcerer/model'; import { useRuleExecutionEvents } from '../../../../../containers/detection_engine/rules'; +import { useRuleDetailsContext } from '../rule_details_context'; import * as i18n from './translations'; import { EXECUTION_LOG_COLUMNS, GET_EXECUTION_LOG_METRICS_COLUMNS } from './execution_log_columns'; import { ExecutionLogSearchBar } from './execution_log_search_bar'; @@ -56,6 +76,12 @@ interface ExecutionLogTableProps { selectAlertsTab: () => void; } +interface CachedGlobalQueryState { + filters: Filter[]; + query: Query; + timerange: AbsoluteTimeRange | RelativeTimeRange; +} + const ExecutionLogTableComponent: React.FC = ({ ruleId, selectAlertsTab, @@ -68,28 +94,84 @@ const ExecutionLogTableComponent: React.FC = ({ storage, timelines, } = useKibana().services; - // Datepicker state - const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); - const [refreshInterval, setRefreshInterval] = useState(1000); - const [isPaused, setIsPaused] = useState(true); - const [start, setStart] = useState('now-24h'); - const [end, setEnd] = useState('now'); - // Searchbar/Filter/Settings state - const [queryText, setQueryText] = useState(''); - const [statusFilters, setStatusFilters] = useState(undefined); - const [showMetricColumns, setShowMetricColumns] = useState( - storage.get(RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY) ?? false - ); + const { + [RuleDetailTabs.executionLogs]: { + state: { + superDatePicker: { recentlyUsedRanges, refreshInterval, isPaused, start, end }, + queryText, + statusFilters, + showMetricColumns, + pagination: { pageIndex, pageSize }, + sort: { sortField, sortDirection }, + }, + actions: { + setEnd, + setIsPaused, + setPageIndex, + setPageSize, + setQueryText, + setRecentlyUsedRanges, + setRefreshInterval, + setShowMetricColumns, + setSortDirection, + setSortField, + setStart, + setStatusFilters, + }, + }, + } = useRuleDetailsContext(); - // Pagination state - const [pageIndex, setPageIndex] = useState(1); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('timestamp'); - const [sortDirection, setSortDirection] = useState('desc'); // Index for `add filter` action and toasts for errors const { indexPattern } = useSourcererDataView(SourcererScopeName.detections); - const { addError, addSuccess } = useAppToasts(); + const { addError, addSuccess, remove } = useAppToasts(); + + // QueryString, Filters, and TimeRange state + const dispatch = useDispatch(); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const timerange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const cachedGlobalQueryState = useRef({ filters, query, timerange }); + const successToastId = useRef(''); + + const resetGlobalQueryState = useCallback(() => { + if (isAbsoluteTimeRange(cachedGlobalQueryState.current.timerange)) { + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: cachedGlobalQueryState.current.timerange.from, + to: cachedGlobalQueryState.current.timerange.to, + }) + ); + } else if (isRelativeTimeRange(cachedGlobalQueryState.current.timerange)) { + dispatch( + setRelativeRangeDatePicker({ + id: 'global', + from: cachedGlobalQueryState.current.timerange.from, + fromStr: cachedGlobalQueryState.current.timerange.fromStr, + to: cachedGlobalQueryState.current.timerange.to, + toStr: cachedGlobalQueryState.current.timerange.toStr, + }) + ); + } + + dispatch( + setFilterQuery({ + id: 'global', + query: cachedGlobalQueryState.current.query.query, + language: cachedGlobalQueryState.current.query.language, + }) + ); + // Using filterManager directly as dispatch(setSearchBarFilter()) was not replacing filters + filterManager.removeAll(); + filterManager.addFilters(cachedGlobalQueryState.current.filters); + remove(successToastId.current); + }, [dispatch, filterManager, remove]); // Table data state const { @@ -118,15 +200,18 @@ const ExecutionLogTableComponent: React.FC = ({ }, [indexPattern]); // Callbacks - const onTableChangeCallback = useCallback(({ page = {}, sort = {} }) => { - const { index, size } = page; - const { field, direction } = sort; + const onTableChangeCallback = useCallback( + ({ page = {}, sort = {} }) => { + const { index, size } = page; + const { field, direction } = sort; - setPageIndex(index + 1); - setPageSize(size); - setSortField(field); - setSortDirection(direction); - }, []); + setPageIndex(index + 1); + setPageSize(size); + setSortField(field); + setSortDirection(direction); + }, + [setPageIndex, setPageSize, setSortDirection, setSortField] + ); const onTimeChangeCallback = useCallback( (props: OnTimeChangeProps) => { @@ -141,14 +226,17 @@ const ExecutionLogTableComponent: React.FC = ({ recentlyUsedRange.length > 10 ? recentlyUsedRange.slice(0, 9) : recentlyUsedRange ); }, - [recentlyUsedRanges] + [recentlyUsedRanges, setEnd, setRecentlyUsedRanges, setStart] ); - const onRefreshChangeCallback = useCallback((props: OnRefreshChangeProps) => { - setIsPaused(props.isPaused); - // Only support auto-refresh >= 1minute -- no current ability to limit within component - setRefreshInterval(props.refreshInterval > 60000 ? props.refreshInterval : 60000); - }, []); + const onRefreshChangeCallback = useCallback( + (props: OnRefreshChangeProps) => { + setIsPaused(props.isPaused); + // Only support auto-refresh >= 1minute -- no current ability to limit within component + setRefreshInterval(props.refreshInterval > 60000 ? props.refreshInterval : 60000); + }, + [setIsPaused, setRefreshInterval] + ); const onRefreshCallback = useCallback( (props: OnRefreshProps) => { @@ -157,19 +245,26 @@ const ExecutionLogTableComponent: React.FC = ({ [refetch] ); - const onSearchCallback = useCallback((updatedQueryText: string) => { - setQueryText(updatedQueryText); - }, []); + const onSearchCallback = useCallback( + (updatedQueryText: string) => { + setQueryText(updatedQueryText); + }, + [setQueryText] + ); - const onStatusFilterChangeCallback = useCallback((updatedStatusFilters: string[]) => { - setStatusFilters( - updatedStatusFilters.length ? updatedStatusFilters.sort().join(',') : undefined - ); - }, []); + const onStatusFilterChangeCallback = useCallback( + (updatedStatusFilters: RuleExecutionStatus[]) => { + setStatusFilters(updatedStatusFilters); + }, + [setStatusFilters] + ); const onFilterByExecutionIdCallback = useCallback( (executionId: string, executionStart: string) => { if (uuidDataViewField != null) { + // Update cached global query state with current state as a rollback point + cachedGlobalQueryState.current = { filters, query, timerange }; + // Create filter & daterange constraints const filter = buildFilter( indexPattern, uuidDataViewField, @@ -179,20 +274,55 @@ const ExecutionLogTableComponent: React.FC = ({ executionId, null ); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: moment(executionStart).subtract(1, 'days').toISOString(), + to: moment(executionStart).add(1, 'days').toISOString(), + }) + ); filterManager.removeAll(); filterManager.addFilters(filter); + dispatch(setFilterQuery({ id: 'global', query: '', language: 'kuery' })); selectAlertsTab(); - addSuccess({ - title: i18n.ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_TITLE, - text: i18n.ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_DESCRIPTION, - }); + successToastId.current = addSuccess( + { + title: i18n.ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_TITLE, + text: mountReactNode( + <> +

{i18n.ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_DESCRIPTION}

+ + + + {i18n.ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_RESTORE_BUTTON} + + + + + ), + }, + // Essentially keep toast around till user dismisses via 'x' + { toastLifeTimeMs: 10 * 60 * 1000 } + ).id; } else { addError(i18n.ACTIONS_FIELD_NOT_FOUND_ERROR, { title: i18n.ACTIONS_FIELD_NOT_FOUND_ERROR_TITLE, }); } }, - [addError, addSuccess, filterManager, indexPattern, selectAlertsTab, uuidDataViewField] + [ + addError, + addSuccess, + dispatch, + filterManager, + filters, + indexPattern, + query, + resetGlobalQueryState, + selectAlertsTab, + timerange, + uuidDataViewField, + ] ); const onShowMetricColumnsCallback = useCallback( @@ -200,7 +330,7 @@ const ExecutionLogTableComponent: React.FC = ({ storage.set(RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY, showMetrics); setShowMetricColumns(showMetrics); }, - [storage] + [setShowMetricColumns, storage] ); // Memoized state @@ -223,8 +353,6 @@ const ExecutionLogTableComponent: React.FC = ({ }; }, [sortDirection, sortField]); - // TODO: Re-add actions once alert count is displayed in table and UX is finalized - // @ts-expect-error unused constant const actions = useMemo( () => [ { @@ -258,9 +386,9 @@ const ExecutionLogTableComponent: React.FC = ({ const executionLogColumns = useMemo( () => showMetricColumns - ? [...EXECUTION_LOG_COLUMNS, ...GET_EXECUTION_LOG_METRICS_COLUMNS(docLinks)] - : [...EXECUTION_LOG_COLUMNS], - [docLinks, showMetricColumns] + ? [...EXECUTION_LOG_COLUMNS, ...GET_EXECUTION_LOG_METRICS_COLUMNS(docLinks), ...actions] + : [...EXECUTION_LOG_COLUMNS, ...actions], + [actions, docLinks, showMetricColumns] ); return ( @@ -271,6 +399,7 @@ const ExecutionLogTableComponent: React.FC = ({ onSearch={onSearchCallback} onStatusFilterChange={onStatusFilterChangeCallback} onlyShowFilters={true} + defaultSelectedStatusFilters={statusFilters} /> @@ -315,7 +444,7 @@ const ExecutionLogTableComponent: React.FC = ({ - + {timelines.getLastUpdated({ showUpdating: isLoading || isFetching, updatedAt: dataUpdatedAt, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.test.tsx index 44f765f934ae8..30e7d6e8f0a2e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.test.tsx @@ -5,17 +5,82 @@ * 2.0. */ -import { shallow } from 'enzyme'; +import { render } from '@testing-library/react'; import React from 'react'; -import { RuleDurationFormat } from './rule_duration_format'; - -// TODO: Replace snapshot test with base test cases +import { getFormattedDuration, RuleDurationFormat } from './rule_duration_format'; describe('RuleDurationFormat', () => { - describe('snapshots', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + describe('getFormattedDuration', () => { + test('if input value is 0, formatted response is also 0', () => { + const formattedDuration = getFormattedDuration(0); + expect(formattedDuration).toEqual('00:00:00:000'); + }); + + test('if input value only contains ms, formatted response also only contains ms (SSS)', () => { + const formattedDuration = getFormattedDuration(999); + expect(formattedDuration).toEqual('00:00:00:999'); + }); + + test('for milliseconds (SSS) to seconds (ss) overflow', () => { + const formattedDuration = getFormattedDuration(1000); + expect(formattedDuration).toEqual('00:00:01:000'); + }); + + test('for seconds (ss) to minutes (mm) overflow', () => { + const formattedDuration = getFormattedDuration(60000 + 1); + expect(formattedDuration).toEqual('00:01:00:001'); + }); + + test('for minutes (mm) to hours (hh) overflow', () => { + const formattedDuration = getFormattedDuration(60000 * 60 + 1); + expect(formattedDuration).toEqual('01:00:00:001'); + }); + + test('for hours (hh) to days (ddd) overflow', () => { + const formattedDuration = getFormattedDuration(60000 * 60 * 24 + 1); + expect(formattedDuration).toEqual('001:00:00:00:001'); + }); + + test('for overflow with all units up to hours (hh)', () => { + const formattedDuration = getFormattedDuration(25033167); + expect(formattedDuration).toEqual('06:57:13:167'); + }); + + test('for overflow with all units up to hours (ddd)', () => { + const formattedDuration = getFormattedDuration(2503316723); + expect(formattedDuration).toEqual('028:23:21:56:723'); + }); + + test('for overflow greater than a year', () => { + const formattedDuration = getFormattedDuration((60000 * 60 * 24 + 1) * 365); + expect(formattedDuration).toEqual('> 1 Year'); + }); + + test('for max overflow', () => { + const formattedDuration = getFormattedDuration(Number.MAX_SAFE_INTEGER); + expect(formattedDuration).toEqual('> 1 Year'); + }); + }); + + describe('RuleDurationFormatComponent', () => { + test('renders correctly with duration and no additional props', () => { + const { getByTestId } = render(); + expect(getByTestId('rule-duration-format-value')).toHaveTextContent('00:00:01:000'); + }); + + test('renders correctly with duration and isSeconds=true', () => { + const { getByTestId } = render(); + expect(getByTestId('rule-duration-format-value')).toHaveTextContent('00:00:01:000'); + }); + + test('renders correctly with allowZero=true', () => { + const { getByTestId } = render(); + expect(getByTestId('rule-duration-format-value')).toHaveTextContent('N/A'); + }); + + test('renders correctly with max overflow', () => { + const { getByTestId } = render(); + expect(getByTestId('rule-duration-format-value')).toHaveTextContent('> 1 Year'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.tsx index cdbc19ce16c3b..c9f8e1d2734d8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/rule_duration_format.tsx @@ -5,48 +5,59 @@ * 2.0. */ -import numeral from '@elastic/numeral'; import moment from 'moment'; import React, { useMemo } from 'react'; +import * as i18n from './translations'; + interface Props { duration: number; - isMillis?: boolean; + isSeconds?: boolean; allowZero?: boolean; } -export function getFormattedDuration(value: number) { +export const getFormattedDuration = (value: number) => { if (!value) { return '00:00:00:000'; } const duration = moment.duration(value); - const hours = Math.floor(duration.asHours()).toString().padStart(2, '0'); - const minutes = Math.floor(duration.asMinutes()).toString().padStart(2, '0'); + const days = Math.floor(duration.asDays()).toString().padStart(3, '0'); + const hours = Math.floor(duration.asHours() % 24) + .toString() + .padStart(2, '0'); + const minutes = Math.floor(duration.asMinutes() % 60) + .toString() + .padStart(2, '0'); const seconds = duration.seconds().toString().padStart(2, '0'); const ms = duration.milliseconds().toString().padStart(3, '0'); - return `${hours}:${minutes}:${seconds}:${ms}`; -} -export function getFormattedMilliseconds(value: number) { - const formatted = numeral(value).format('0,0'); - return `${formatted} ms`; -} + if (Math.floor(duration.asDays()) > 0) { + if (Math.floor(duration.asDays()) >= 365) { + return i18n.GREATER_THAN_YEAR; + } else { + return `${days}:${hours}:${minutes}:${seconds}:${ms}`; + } + } else { + return `${hours}:${minutes}:${seconds}:${ms}`; + } +}; /** - * Formats duration as (hh:mm:ss:SSS) - * @param props duration default as nanos, set isMillis:true to pass in ms + * Formats duration as (hh:mm:ss:SSS) by default, overflowing to include days + * as (ddd:hh:mm:ss:SSS) if necessary, and then finally to `> 1 Year` + * @param props duration as millis, set isSeconds:true to pass in seconds * @constructor */ const RuleDurationFormatComponent = (props: Props) => { - const { duration, isMillis = false, allowZero = true } = props; + const { duration, isSeconds = false, allowZero = true } = props; const formattedDuration = useMemo(() => { // Durations can be buggy and return negative if (allowZero && duration >= 0) { - return getFormattedDuration(isMillis ? duration * 1000 : duration); + return getFormattedDuration(isSeconds ? duration * 1000 : duration); } - return 'N/A'; - }, [allowZero, duration, isMillis]); + return i18n.DURATION_NOT_AVAILABLE; + }, [allowZero, duration, isSeconds]); return {formattedDuration}; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/translations.ts index d5d8d56664907..b161ae3662e0e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/execution_log_table/translations.ts @@ -181,6 +181,13 @@ export const ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_DESCRIPTION = i18n.transla } ); +export const ACTIONS_SEARCH_FILTERS_HAVE_BEEN_UPDATED_RESTORE_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.ruleExecutionLog.actionSearchFiltersUpdatedRestoreButtonTitle', + { + defaultMessage: 'Restore previous filters', + } +); + export const ACTIONS_FIELD_NOT_FOUND_ERROR_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDetails.ruleExecutionLog.actionFieldNotFoundErrorTitle', { @@ -194,3 +201,17 @@ export const ACTIONS_FIELD_NOT_FOUND_ERROR = i18n.translate( defaultMessage: "Cannot find field 'kibana.alert.rule.execution.uuid' in alerts index.", } ); + +export const DURATION_NOT_AVAILABLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.ruleExecutionLog.durationNotAvailableDescription', + { + defaultMessage: 'N/A', + } +); + +export const GREATER_THAN_YEAR = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.ruleExecutionLog.durationGreaterThanYearDescription', + { + defaultMessage: '> 1 Year', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 791a3fcd0a5b4..d1be8ce70bd20 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -108,6 +108,7 @@ import { import * as detectionI18n from '../../translations'; import * as ruleI18n from '../translations'; import { ExecutionLogTable } from './execution_log_table/execution_log_table'; +import { RuleDetailsContextProvider } from './rule_details_context'; import * as i18n from './translations'; import { NeedAdminForUpdateRulesCallOut } from '../../../../components/callouts/need_admin_for_update_callout'; import { MissingPrivilegesCallOut } from '../../../../components/callouts/missing_privileges_callout'; @@ -131,7 +132,14 @@ const StyledFullHeightContainer = styled.div` flex: 1 1 auto; `; -enum RuleDetailTabs { +/** + * Sets min-height on tab container to minimize page hop when switching to tabs with less content + */ +const StyledMinHeightTabContainer = styled.div` + min-height: 800px; +`; + +export enum RuleDetailTabs { alerts = 'alerts', executionLogs = 'executionLogs', exceptions = 'exceptions', @@ -639,181 +647,186 @@ const RuleDetailsPageComponent: React.FC = ({ indexPattern={indexPattern} /> - - - - - - - {ruleStatusI18n.STATUS} - {':'} - - {ruleStatusInfo} - - - } - title={title} - badgeOptions={badgeOptions} - > - - - - - - {i18n.ENABLE_RULE} + + + + + + + {ruleStatusI18n.STATUS} + {':'} + + {ruleStatusInfo} - - - - - - {editRule} - - - - - - - - {ruleError} - {getLegacyUrlConflictCallout} - - - - - - - - - - - {defineRuleData != null && ( - + } + title={title} + badgeOptions={badgeOptions} + > + + + + + - )} - + {i18n.ENABLE_RULE} + + - - - - {scheduleRuleData != null && ( - + + {editRule} + + - )} - + + - - - - {tabs} - - - {ruleDetailTab === RuleDetailTabs.alerts && hasIndexRead && ( - <> - - - + {ruleError} + {getLegacyUrlConflictCallout} + + + + - - {updatedAt && - timelinesUi.getLastUpdated({ - updatedAt: updatedAt || Date.now(), - showUpdating, - })} + + + + + + {defineRuleData != null && ( + + )} + + + + + + {scheduleRuleData != null && ( + + )} + + + - - - - - - {ruleId != null && ( - + {tabs} + + + + {ruleDetailTab === RuleDetailTabs.alerts && hasIndexRead && ( + <> + + + + + + {updatedAt && + timelinesUi.getLastUpdated({ + updatedAt: updatedAt || Date.now(), + showUpdating, + })} + + + + + + + + {ruleId != null && ( + + )} + + )} + {ruleDetailTab === RuleDetailTabs.exceptions && ( + )} - - )} - {ruleDetailTab === RuleDetailTabs.exceptions && ( - - )} - {ruleDetailTab === RuleDetailTabs.executionLogs && ( - - )} - + {ruleDetailTab === RuleDetailTabs.executionLogs && ( + + )} + + + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/rule_details_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/rule_details_context.tsx new file mode 100644 index 0000000000000..13b17d0493d43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/rule_details_context.tsx @@ -0,0 +1,216 @@ +/* + * 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 { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; +import React, { createContext, useContext, useMemo, useState } from 'react'; +import { RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY } from '../../../../../../common/constants'; +import { + AggregateRuleExecutionEvent, + RuleExecutionStatus, +} from '../../../../../../common/detection_engine/schemas/common'; +import { invariant } from '../../../../../../common/utils/invariant'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { RuleDetailTabs } from '.'; + +export interface ExecutionLogTableState { + /** + * State of the SuperDatePicker component + */ + superDatePicker: { + /** + * DateRanges to display as recently used + */ + recentlyUsedRanges: DurationRange[]; + /** + * Interval to auto-refresh at + */ + refreshInterval: number; + /** + * State of auto-refresh + */ + isPaused: boolean; + /** + * Start datetime + */ + start: string; + /** + * End datetime + */ + end: string; + }; + /** + * SearchBar query + */ + queryText: string; + /** + * Selected Filters by Execution Status(es) + */ + statusFilters: RuleExecutionStatus[]; + /** + * Whether or not to show additional metric columnbs + */ + showMetricColumns: boolean; + /** + * Currently selected page and number of rows per page + */ + pagination: { + pageIndex: number; + pageSize: number; + }; + sort: { + sortField: keyof AggregateRuleExecutionEvent; + sortDirection: SortOrder; + }; +} + +// @ts-expect-error unused constant +const DEFAULT_STATE: ExecutionLogTableState = { + superDatePicker: { + recentlyUsedRanges: [], + refreshInterval: 1000, + isPaused: true, + start: 'now-24hr', + end: 'now', + }, + queryText: '', + statusFilters: [], + showMetricColumns: false, + pagination: { + pageIndex: 1, + pageSize: 5, + }, + sort: { + sortField: 'timestamp', + sortDirection: 'desc', + }, +}; + +export interface ExecutionLogTableActions { + setRecentlyUsedRanges: React.Dispatch>; + setRefreshInterval: React.Dispatch>; + setIsPaused: React.Dispatch>; + setStart: React.Dispatch>; + setEnd: React.Dispatch>; + setQueryText: React.Dispatch>; + setStatusFilters: React.Dispatch>; + setShowMetricColumns: React.Dispatch>; + setPageIndex: React.Dispatch>; + setPageSize: React.Dispatch>; + setSortField: React.Dispatch>; + setSortDirection: React.Dispatch>; +} + +export interface RuleDetailsContextType { + // TODO: Add section for RuleDetailTabs.exceptions and store query/pagination/etc. + // TODO: Let's discuss how to integration with ExceptionsViewerComponent state mgmt + [RuleDetailTabs.executionLogs]: { + state: ExecutionLogTableState; + actions: ExecutionLogTableActions; + }; +} + +const RuleDetailsContext = createContext(null); + +interface RuleDetailsContextProviderProps { + children: React.ReactNode; +} + +export const RuleDetailsContextProvider = ({ children }: RuleDetailsContextProviderProps) => { + const { storage } = useKibana().services; + + // Execution Log Table tab + // // SuperDatePicker State + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); + const [refreshInterval, setRefreshInterval] = useState(1000); + const [isPaused, setIsPaused] = useState(true); + const [start, setStart] = useState('now-24h'); + const [end, setEnd] = useState('now'); + // Searchbar/Filter/Settings state + const [queryText, setQueryText] = useState(''); + const [statusFilters, setStatusFilters] = useState([]); + const [showMetricColumns, setShowMetricColumns] = useState( + storage.get(RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY) ?? false + ); + // Pagination state + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState('timestamp'); + const [sortDirection, setSortDirection] = useState('desc'); + // // End Execution Log Table tab + + const providerValue = useMemo( + () => ({ + [RuleDetailTabs.executionLogs]: { + state: { + superDatePicker: { + recentlyUsedRanges, + refreshInterval, + isPaused, + start, + end, + }, + queryText, + statusFilters, + showMetricColumns, + pagination: { + pageIndex, + pageSize, + }, + sort: { + sortField, + sortDirection, + }, + }, + actions: { + setEnd, + setIsPaused, + setPageIndex, + setPageSize, + setQueryText, + setRecentlyUsedRanges, + setRefreshInterval, + setShowMetricColumns, + setSortDirection, + setSortField, + setStart, + setStatusFilters, + }, + }, + }), + [ + end, + isPaused, + pageIndex, + pageSize, + queryText, + recentlyUsedRanges, + refreshInterval, + showMetricColumns, + sortDirection, + sortField, + start, + statusFilters, + ] + ); + + return ( + {children} + ); +}; + +export const useRuleDetailsContext = (): RuleDetailsContextType => { + const ruleDetailsContext = useContext(RuleDetailsContext); + invariant( + ruleDetailsContext, + 'useRuleDetailsContext should be used inside RuleDetailsContextProvider' + ); + return ruleDetailsContext; +}; + +export const useRuleDetailsContextOptional = (): RuleDetailsContextType | null => + useContext(RuleDetailsContext); diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index bf300569d6e23..29327d2eb64ba 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -106,6 +106,7 @@ jest.mock('../../common/lib/kibana', () => { addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }), }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx index dd032016088b6..ee4af121dd054 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -35,6 +35,7 @@ jest.mock('../../common/lib/kibana', () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + remove: jest.fn(), }), useKibana: jest.fn().mockReturnValue({ services: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index ae4da9ac98415..212c48794aac8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -581,7 +581,7 @@ export const getAggregateExecutionEvents = (): GetAggregateRuleExecutionEventsRe timed_out: false, indexing_duration_ms: 7, search_duration_ms: 551, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -604,7 +604,7 @@ export const getAggregateExecutionEvents = (): GetAggregateRuleExecutionEventsRe timed_out: false, indexing_duration_ms: 0, search_duration_ms: 0, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'partial failure', security_message: 'Check privileges failed to execute ResponseError: index_not_found_exception: [index_not_found_exception] Reason: no such index [broken-index] name: "This Rule Makes Alerts, Actions, AND Moar!" id: "f78f3550-a186-11ec-89a1-0bce95157aba" rule id: "b64b4540-d035-4826-a1e7-f505bf4b9653" execution id: "254d8400-9dc7-43c5-ad4b-227273d1a44b" space ID: "default"', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_rule_execution_events_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_rule_execution_events_route.test.ts index b24789d77a9bb..b41c7e6537fcf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_rule_execution_events_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_rule_execution_events_route.test.ts @@ -12,8 +12,6 @@ import { } from '../__mocks__/request_responses'; import { getRuleExecutionEventsRoute } from './get_rule_execution_events_route'; -// TODO: Add additional tests for param validation - describe('getRuleExecutionEventsRoute', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/client_for_routes/client_interface.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/client_for_routes/client_interface.ts index 8d0cae91f1987..3f353732abbc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/client_for_routes/client_interface.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/client_for_routes/client_interface.ts @@ -9,7 +9,7 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ExecutionLogTableSortColumns, RuleExecutionEvent, - RuleExecutionStatusType, + RuleExecutionStatus, RuleExecutionSummary, } from '../../../../../common/detection_engine/schemas/common'; import { GetAggregateRuleExecutionEventsResponse } from '../../../../../common/detection_engine/schemas/response'; @@ -19,7 +19,7 @@ export interface GetAggregateExecutionEventsArgs { start: string; end: string; queryText: string; - statusFilters: RuleExecutionStatusType[]; + statusFilters: RuleExecutionStatus[]; page: number; perPage: number; sortField: ExecutionLogTableSortColumns; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts index f2ee7fa7c3097..f0f0259134638 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/event_log_reader.ts @@ -18,13 +18,14 @@ import { invariant } from '../../../../../common/utils/invariant'; import { withSecuritySpan } from '../../../../utils/with_security_span'; import { GetAggregateExecutionEventsArgs } from '../client_for_routes/client_interface'; import { - RULE_SAVED_OBJECT_TYPE, RULE_EXECUTION_LOG_PROVIDER, + RULE_SAVED_OBJECT_TYPE, RuleExecutionLogAction, } from './constants'; import { formatExecutionEventResponse, getExecutionEventAggregation, + mapRuleExecutionStatusToPlatformStatus, } from './get_execution_event_aggregation'; import { EXECUTION_UUID_FIELD, @@ -62,10 +63,15 @@ export const createEventLogReader = (eventLog: IEventLogClient): IEventLogReader let totalExecutions: number | undefined; // If 0 or 3 statuses are selected we can search for all statuses and don't need this pre-filter by ID if (statusFilters.length > 0 && statusFilters.length < 3) { + const outcomes = mapRuleExecutionStatusToPlatformStatus(statusFilters); + const outcomeFilter = outcomes.length ? `OR event.outcome:(${outcomes.join(' OR ')})` : ''; const statusResults = await eventLog.aggregateEventsBySavedObjectIds(soType, soIds, { start, end, - filter: `kibana.alert.rule.execution.status:(${statusFilters.join(' OR ')})`, + // Also query for `event.outcome` to catch executions that only contain platform events + filter: `kibana.alert.rule.execution.status:(${statusFilters.join( + ' OR ' + )}) ${outcomeFilter}`, aggs: { totalExecutions: { cardinality: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.test.ts index baa4dd572ced0..dcd592d7a70fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.test.ts @@ -12,6 +12,7 @@ * 2.0. */ +import { RuleExecutionStatus } from '../../../../../../common/detection_engine/schemas/common'; import { MAX_EXECUTION_EVENTS_DISPLAYED } from '@kbn/securitysolution-rules'; import { @@ -20,7 +21,9 @@ import { formatSortForTermsSort, getExecutionEventAggregation, getProviderAndActionFilter, -} from './index'; + mapPlatformStatusToRuleExecutionStatus, + mapRuleExecutionStatusToPlatformStatus, +} from '.'; describe('getExecutionEventAggregation', () => { test('should throw error when given bad maxExecutions field', () => { @@ -69,7 +72,7 @@ describe('getExecutionEventAggregation', () => { sort: [{ notsortable: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,duration_ms,indexing_duration_ms,search_duration_ms,gap_duration_ms,schedule_delay_ms,num_triggered_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,duration_ms,indexing_duration_ms,search_duration_ms,gap_duration_s,schedule_delay_ms,num_triggered_actions]"` ); }); @@ -82,7 +85,7 @@ describe('getExecutionEventAggregation', () => { sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,duration_ms,indexing_duration_ms,search_duration_ms,gap_duration_ms,schedule_delay_ms,num_triggered_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,duration_ms,indexing_duration_ms,search_duration_ms,gap_duration_s,schedule_delay_ms,num_triggered_actions]"` ); }); @@ -206,7 +209,7 @@ describe('getExecutionEventAggregation', () => { top_hits: { size: 1, _source: { - includes: ['event.outcome', 'message'], + includes: ['error.message', 'event.outcome', 'message'], }, }, }, @@ -647,7 +650,7 @@ describe('formatExecutionEventResponse', () => { timed_out: false, indexing_duration_ms: 7, search_duration_ms: 480, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -670,7 +673,7 @@ describe('formatExecutionEventResponse', () => { timed_out: false, indexing_duration_ms: 0, search_duration_ms: 9, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -965,7 +968,7 @@ describe('formatExecutionEventResponse', () => { timed_out: true, indexing_duration_ms: 7, search_duration_ms: 480, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -988,7 +991,7 @@ describe('formatExecutionEventResponse', () => { timed_out: false, indexing_duration_ms: 0, search_duration_ms: 9, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -1288,7 +1291,7 @@ describe('formatExecutionEventResponse', () => { timed_out: true, indexing_duration_ms: 7, search_duration_ms: 480, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -1311,7 +1314,7 @@ describe('formatExecutionEventResponse', () => { timed_out: false, indexing_duration_ms: 0, search_duration_ms: 9, - gap_duration_ms: 0, + gap_duration_s: 0, security_status: 'succeeded', security_message: 'succeeded', }, @@ -1319,3 +1322,53 @@ describe('formatExecutionEventResponse', () => { }); }); }); + +describe('mapRuleStatusToPlatformStatus', () => { + test('should correctly translate empty array to empty array', () => { + expect(mapRuleExecutionStatusToPlatformStatus([])).toEqual([]); + }); + + test('should correctly translate RuleExecutionStatus.failed to `failure` platform status', () => { + expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus.failed])).toEqual([ + 'failure', + ]); + }); + + test('should correctly translate RuleExecutionStatus.succeeded to `success` platform status', () => { + expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus.succeeded])).toEqual([ + 'success', + ]); + }); + + test('should correctly translate RuleExecutionStatus.["going to run"] to empty array platform status', () => { + expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus['going to run']])).toEqual( + [] + ); + }); + + test("should correctly translate multiple RuleExecutionStatus's to platform statuses", () => { + expect( + mapRuleExecutionStatusToPlatformStatus([ + RuleExecutionStatus.succeeded, + RuleExecutionStatus.failed, + RuleExecutionStatus['going to run'], + ]).sort() + ).toEqual(['failure', 'success']); + }); +}); + +describe('mapPlatformStatusToRuleExecutionStatus', () => { + test('should correctly translate `invalid` platform status to `undefined`', () => { + expect(mapPlatformStatusToRuleExecutionStatus('')).toEqual(undefined); + }); + + test('should correctly translate `failure` platform status to `RuleExecutionStatus.failed`', () => { + expect(mapPlatformStatusToRuleExecutionStatus('failure')).toEqual(RuleExecutionStatus.failed); + }); + + test('should correctly translate `success` platform status to `RuleExecutionStatus.succeeded`', () => { + expect(mapPlatformStatusToRuleExecutionStatus('success')).toEqual( + RuleExecutionStatus.succeeded + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.ts index 4b1bd1f185198..704c261094109 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log/get_execution_event_aggregation/index.ts @@ -10,7 +10,10 @@ import { BadRequestError } from '@kbn/securitysolution-es-utils'; import { flatMap, get } from 'lodash'; import { MAX_EXECUTION_EVENTS_DISPLAYED } from '@kbn/securitysolution-rules'; import { AggregateEventsBySavedObjectResult } from '../../../../../../../event_log/server'; -import { AggregateRuleExecutionEvent } from '../../../../../../common/detection_engine/schemas/common'; +import { + AggregateRuleExecutionEvent, + RuleExecutionStatus, +} from '../../../../../../common/detection_engine/schemas/common'; import { GetAggregateRuleExecutionEventsResponse } from '../../../../../../common/detection_engine/schemas/response'; import { ExecutionEventAggregationOptions, @@ -22,6 +25,7 @@ import { // Base ECS fields const ACTION_FIELD = 'event.action'; const DURATION_FIELD = 'event.duration'; +const ERROR_MESSAGE_FIELD = 'error.message'; const MESSAGE_FIELD = 'message'; const PROVIDER_FIELD = 'event.provider'; const OUTCOME_FIELD = 'event.outcome'; @@ -48,7 +52,7 @@ const SORT_FIELD_TO_AGG_MAPPING: Record = { duration_ms: 'ruleExecution>executionDuration', indexing_duration_ms: 'securityMetrics>indexDuration', search_duration_ms: 'securityMetrics>searchDuration', - gap_duration_ms: 'securityMetrics>gapDuration', + gap_duration_s: 'securityMetrics>gapDuration', schedule_delay_ms: 'ruleExecution>scheduleDelay', num_triggered_actions: 'ruleExecution>numTriggeredActions', // TODO: To be added in https://github.com/elastic/kibana/pull/126210 @@ -166,7 +170,7 @@ export const getExecutionEventAggregation = ({ top_hits: { size: 1, _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD], + includes: [ERROR_MESSAGE_FIELD, OUTCOME_FIELD, MESSAGE_FIELD], }, }, }, @@ -293,11 +297,18 @@ export const formatAggExecutionEventFromBucket = ( // security fields indexing_duration_ms: bucket?.securityMetrics?.indexDuration?.value ?? 0, search_duration_ms: bucket?.securityMetrics?.searchDuration?.value ?? 0, - gap_duration_ms: bucket?.securityMetrics?.gapDuration?.value ?? 0, + gap_duration_s: bucket?.securityMetrics?.gapDuration?.value ?? 0, + // If security_status isn't available, use platform status from `event.outcome`, but translate to RuleExecutionStatus security_status: bucket?.securityStatus?.status?.hits?.hits[0]?._source?.kibana?.alert?.rule?.execution - ?.status, - security_message: bucket?.securityStatus?.message?.hits?.hits[0]?._source?.message, + ?.status ?? + mapPlatformStatusToRuleExecutionStatus( + bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.event?.outcome + ), + // If security_message isn't available, use `error.message` instead for platform errors since it is more descriptive than `message` + security_message: + bucket?.securityStatus?.message?.hits?.hits[0]?._source?.message ?? + bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.error?.message, }; }; @@ -353,3 +364,40 @@ export const formatSortForTermsSort = (sort: estypes.Sort) => { ) ); }; + +/** + * Maps a RuleExecutionStatus[] to string[] of associated platform statuses. Useful for querying specific platform + * events based on security status values + * @param ruleStatuses RuleExecutionStatus[] + */ +export const mapRuleExecutionStatusToPlatformStatus = ( + ruleStatuses: RuleExecutionStatus[] +): string[] => { + return flatMap(ruleStatuses, (rs) => { + switch (rs) { + case RuleExecutionStatus.failed: + return 'failure'; + case RuleExecutionStatus.succeeded: + return 'success'; + default: + return []; + } + }); +}; + +/** + * Maps a platform status string to RuleExecutionStatus + * @param platformStatus string, i.e. `failure` or `success` + */ +export const mapPlatformStatusToRuleExecutionStatus = ( + platformStatus: string +): RuleExecutionStatus | undefined => { + switch (platformStatus) { + case 'failure': + return RuleExecutionStatus.failed; + case 'success': + return RuleExecutionStatus.succeeded; + default: + return undefined; + } +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_rule_execution_events.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_rule_execution_events.ts index b8287c4433ba4..eacb11fecbb04 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_rule_execution_events.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_rule_execution_events.ts @@ -52,11 +52,6 @@ export default ({ getService }: FtrProviderContext) => { await deleteAllEventLogExecutionEvents(es, log); }); - afterEach(async () => { - await deleteAllAlerts(supertest, log); - await deleteAllEventLogExecutionEvents(es, log); - }); - it('should return an error if rule does not exist', async () => { const start = dateMath.parse('now-24h')?.utc().toISOString(); const end = dateMath.parse('now', { roundUp: true })?.utc().toISOString(); @@ -90,7 +85,7 @@ export default ({ getService }: FtrProviderContext) => { expect(response.body.events[0].search_duration_ms).to.greaterThan(0); expect(response.body.events[0].schedule_delay_ms).to.greaterThan(0); expect(response.body.events[0].indexing_duration_ms).to.greaterThan(0); - expect(response.body.events[0].gap_duration_ms).to.eql(0); + expect(response.body.events[0].gap_duration_s).to.eql(0); expect(response.body.events[0].security_status).to.eql('succeeded'); expect(response.body.events[0].security_message).to.eql('succeeded'); }); @@ -114,7 +109,7 @@ export default ({ getService }: FtrProviderContext) => { expect(response.body.events[0].search_duration_ms).to.eql(0); expect(response.body.events[0].schedule_delay_ms).to.greaterThan(0); expect(response.body.events[0].indexing_duration_ms).to.eql(0); - expect(response.body.events[0].gap_duration_ms).to.eql(0); + expect(response.body.events[0].gap_duration_s).to.eql(0); expect(response.body.events[0].security_status).to.eql('partial failure'); expect( response.body.events[0].security_message.startsWith( @@ -123,16 +118,15 @@ export default ({ getService }: FtrProviderContext) => { ).to.eql(true); }); - // TODO: Debug indexing - it.skip('should return execution events for a rule that has executed in a failure state with a gap', async () => { + it('should return execution events for a rule that has executed in a failure state with a gap', async () => { const rule = getRuleForSignalTesting(['auditbeat-*'], uuid.v4(), false); const { id } = await createRule(supertest, log, rule); const start = dateMath.parse('now')?.utc().toISOString(); const end = dateMath.parse('now+24h', { roundUp: true })?.utc().toISOString(); - // Create 5 timestamps a minute apart to use in the templated data - const dateTimes = [...Array(5).keys()].map((i) => + // Create 5 timestamps (failedGapExecution.length) a minute apart to use in the templated data + const dateTimes = [...Array(failedGapExecution.length).keys()].map((i) => moment(start) .add(i + 1, 'm') .toDate() @@ -144,6 +138,7 @@ export default ({ getService }: FtrProviderContext) => { set(e, 'event.start', dateTimes[i]); set(e, 'event.end', dateTimes[i]); set(e, 'rule.id', id); + set(e, 'kibana.saved_objects[0].id', id); return e; }); @@ -155,73 +150,19 @@ export default ({ getService }: FtrProviderContext) => { .set('kbn-xsrf', 'true') .query({ start, end }); - // console.log(JSON.stringify(response)); - expect(response.status).to.eql(200); expect(response.body.total).to.eql(1); - expect(response.body.events[0].duration_ms).to.eql(4236); + expect(response.body.events[0].duration_ms).to.eql(1545); expect(response.body.events[0].search_duration_ms).to.eql(0); - expect(response.body.events[0].schedule_delay_ms).to.greaterThan(0); + expect(response.body.events[0].schedule_delay_ms).to.eql(544808); expect(response.body.events[0].indexing_duration_ms).to.eql(0); - expect(response.body.events[0].gap_duration_ms).to.greaterThan(0); + expect(response.body.events[0].gap_duration_s).to.eql(245); expect(response.body.events[0].security_status).to.eql('failed'); expect( response.body.events[0].security_message.startsWith( - 'Check privileges failed to execute ResponseError: index_not_found_exception: [index_not_found_exception] Reason: no such index [no-name-index]' + '4 minutes (244689ms) were not queried between this rule execution and the last execution, so signals may have been missed. Consider increasing your look behind time or adding more Kibana instances.' ) ).to.eql(true); }); - - // it('should return execution events when providing a status filter', async () => { - // const rule = getRuleForSignalTesting(['auditbeat-*', 'no-name-index']); - // const { id } = await createRule(supertest, log, rule); - // await waitForRuleSuccessOrStatus(supertest, log, id, RuleExecutionStatus.failed); - // await waitForSignalsToBePresent(supertest, log, 1, [id]); - // - // const start = dateMath.parse('now-24h')?.utc().toISOString(); - // const end = dateMath.parse('now', { roundUp: true })?.utc().toISOString(); - // const response = await supertest - // .get(detectionEngineRuleExecutionEventsUrl(id)) - // .set('kbn-xsrf', 'true') - // .query({ start, end }); - // - // expect(response.status).to.eql(200); - // expect(response.body.total).to.eql(1); - // expect(response.body.events[0].duration_ms).to.greaterThan(0); - // expect(response.body.events[0].search_duration_ms).to.eql(0); - // expect(response.body.events[0].schedule_delay_ms).to.greaterThan(0); - // expect(response.body.events[0].indexing_duration_ms).to.eql(0); - // expect(response.body.events[0].gap_duration_ms).to.eql(0); - // expect(response.body.events[0].security_status).to.eql('failed'); - // expect(response.body.events[0].security_message).to.include( - // 'were not queried between this rule execution and the last execution, so signals may have been missed. ' - // ); - // }); - - // it('should return execution events when providing a status filter and sortField', async () => { - // const rule = getRuleForSignalTesting(['auditbeat-*', 'no-name-index']); - // const { id } = await createRule(supertest, log, rule); - // await waitForRuleSuccessOrStatus(supertest, log, id, RuleExecutionStatus.failed); - // await waitForSignalsToBePresent(supertest, log, 1, [id]); - // - // const start = dateMath.parse('now-24h')?.utc().toISOString(); - // const end = dateMath.parse('now', { roundUp: true })?.utc().toISOString(); - // const response = await supertest - // .get(detectionEngineRuleExecutionEventsUrl(id)) - // .set('kbn-xsrf', 'true') - // .query({ start, end }); - // - // expect(response.status).to.eql(200); - // expect(response.body.total).to.eql(1); - // expect(response.body.events[0].duration_ms).to.greaterThan(0); - // expect(response.body.events[0].search_duration_ms).to.eql(0); - // expect(response.body.events[0].schedule_delay_ms).to.greaterThan(0); - // expect(response.body.events[0].indexing_duration_ms).to.eql(0); - // expect(response.body.events[0].gap_duration_ms).to.eql(0); - // expect(response.body.events[0].security_status).to.eql('failed'); - // expect(response.body.events[0].security_message).to.include( - // 'were not queried between this rule execution and the last execution, so signals may have been missed. ' - // ); - // }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/template_data/execution_events.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/template_data/execution_events.ts index c4767bbcc5632..4a674c52fa1ed 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/template_data/execution_events.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/template_data/execution_events.ts @@ -5,6 +5,20 @@ * 2.0. */ +/** + * When using these execution events as templates be sure to replace all the following fields with their updated values + * + * E.g. + * set(e, '@timestamp', dateTimes[i]); + * set(e, 'event.start', dateTimes[i]); + * set(e, 'event.end', dateTimes[i]); + * set(e, 'rule.id', id); + * set(e, 'kibana.saved_objects[0].id', id); + */ + +/** + * Rule executed without issue + */ export const successfulExecution = [ { '@timestamp': '2022-03-17T22:59:31.360Z', @@ -226,30 +240,24 @@ export const successfulExecution = [ }, ]; +/** + * Rule execution identified gap since last execution + */ export const failedGapExecution = [ { - '@timestamp': '2022-03-17T12:36:16.413Z', + '@timestamp': '2022-03-17T12:36:14.868Z', event: { provider: 'alerting', - action: 'execute', + action: 'execute-start', kind: 'alert', category: ['siem'], start: '2022-03-17T12:36:14.868Z', - outcome: 'success', - end: '2022-03-17T12:36:16.413Z', - duration: 1545000000, }, kibana: { alert: { rule: { execution: { uuid: '38fa2d4a-94d3-4ea3-80d6-d1284eb98357', - metrics: { - number_of_triggered_actions: 0, - number_of_searches: 6, - es_search_duration_ms: 2, - total_search_duration_ms: 15, - }, }, }, }, @@ -265,9 +273,6 @@ export const failedGapExecution = [ scheduled: '2022-03-17T12:27:10.060Z', schedule_delay: 544808000000, }, - alerting: { - status: 'ok', - }, server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', version: '8.2.0', }, @@ -276,22 +281,21 @@ export const failedGapExecution = [ license: 'basic', category: 'siem.queryRule', ruleset: 'siem', - name: 'Lots of Execution Events', }, - message: - "rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'", + message: 'rule execution start: "fb1fc150-a292-11ec-a2cf-c1b28b0392b0"', ecs: { version: '1.8.0', }, }, { - '@timestamp': '2022-03-17T12:36:15.382Z', + '@timestamp': '2022-03-17T12:36:14.888Z', event: { provider: 'securitySolution.ruleExecution', - kind: 'metric', - action: 'execution-metrics', - sequence: 1, + kind: 'event', + action: 'status-change', + sequence: 0, }, + message: '', rule: { id: 'fb1fc150-a292-11ec-a2cf-c1b28b0392b0', name: 'Lots of Execution Events', @@ -301,9 +305,8 @@ export const failedGapExecution = [ alert: { rule: { execution: { - metrics: { - execution_gap_duration_s: 245, - }, + status: 'running', + status_order: 15, uuid: '38fa2d4a-94d3-4ea3-80d6-d1284eb98357', }, }, @@ -364,14 +367,13 @@ export const failedGapExecution = [ }, }, { - '@timestamp': '2022-03-17T12:36:14.888Z', + '@timestamp': '2022-03-17T12:36:15.382Z', event: { provider: 'securitySolution.ruleExecution', - kind: 'event', - action: 'status-change', - sequence: 0, + kind: 'metric', + action: 'execution-metrics', + sequence: 1, }, - message: '', rule: { id: 'fb1fc150-a292-11ec-a2cf-c1b28b0392b0', name: 'Lots of Execution Events', @@ -381,8 +383,9 @@ export const failedGapExecution = [ alert: { rule: { execution: { - status: 'running', - status_order: 15, + metrics: { + execution_gap_duration_s: 245, + }, uuid: '38fa2d4a-94d3-4ea3-80d6-d1284eb98357', }, }, @@ -403,19 +406,28 @@ export const failedGapExecution = [ }, }, { - '@timestamp': '2022-03-17T12:36:14.868Z', + '@timestamp': '2022-03-17T12:36:16.413Z', event: { provider: 'alerting', - action: 'execute-start', + action: 'execute', kind: 'alert', category: ['siem'], start: '2022-03-17T12:36:14.868Z', + outcome: 'success', + end: '2022-03-17T12:36:16.413Z', + duration: 1545000000, }, kibana: { alert: { rule: { execution: { uuid: '38fa2d4a-94d3-4ea3-80d6-d1284eb98357', + metrics: { + number_of_triggered_actions: 0, + number_of_searches: 6, + es_search_duration_ms: 2, + total_search_duration_ms: 15, + }, }, }, }, @@ -431,6 +443,9 @@ export const failedGapExecution = [ scheduled: '2022-03-17T12:27:10.060Z', schedule_delay: 544808000000, }, + alerting: { + status: 'ok', + }, server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', version: '8.2.0', }, @@ -439,14 +454,19 @@ export const failedGapExecution = [ license: 'basic', category: 'siem.queryRule', ruleset: 'siem', + name: 'Lots of Execution Events', }, - message: 'rule execution start: "fb1fc150-a292-11ec-a2cf-c1b28b0392b0"', + message: + "rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'", ecs: { version: '1.8.0', }, }, ]; +/** + * Rule execution resulted in partial warning, e.g. missing index pattern + */ export const partialWarningExecution = [ { '@timestamp': '2022-03-16T23:28:36.012Z', @@ -628,3 +648,112 @@ export const partialWarningExecution = [ }, }, ]; + +/** + * Rule execution failed because rule is disabled (configure 1s interval/lookback then rule + * is disabled while running) + */ +export const failedRanAfterDisabled = [ + { + '@timestamp': '2022-04-21T02:00:55.400Z', + event: { + provider: 'alerting', + action: 'execute', + kind: 'alert', + category: ['siem'], + start: '2022-04-21T02:00:55.397Z', + end: '2022-04-21T02:00:55.400Z', + duration: 3000000, + reason: 'disabled', + outcome: 'failure', + }, + kibana: { + alert: { + rule: { + rule_type_id: 'siem.queryRule', + consumer: 'siem', + execution: { + uuid: '50eb8b2e-8334-4387-b77f-d47fdb7fbe2d', + }, + }, + }, + saved_objects: [ + { + rel: 'primary', + type: 'alert', + id: 'a890e240-b9fb-11ec-8598-338317271cf4', + type_id: 'siem.queryRule', + }, + ], + space_ids: ['default'], + task: { + scheduled: '2022-04-21T02:00:53.325Z', + schedule_delay: 2072000000, + }, + alerting: { + status: 'error', + }, + server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', + version: '8.3.0', + }, + rule: { + id: 'a890e240-b9fb-11ec-8598-338317271cf4', + license: 'basic', + category: 'siem.queryRule', + ruleset: 'siem', + }, + error: { + message: 'Rule failed to execute because rule ran after it was disabled.', + }, + message: 'siem.queryRule:a890e240-b9fb-11ec-8598-338317271cf4: execution failed', + ecs: { + version: '1.8.0', + }, + }, + { + '@timestamp': '2022-04-21T02:00:55.397Z', + event: { + provider: 'alerting', + action: 'execute-start', + kind: 'alert', + category: ['siem'], + start: '2022-04-21T02:00:55.397Z', + }, + kibana: { + alert: { + rule: { + rule_type_id: 'siem.queryRule', + consumer: 'siem', + execution: { + uuid: '50eb8b2e-8334-4387-b77f-d47fdb7fbe2d', + }, + }, + }, + saved_objects: [ + { + rel: 'primary', + type: 'alert', + id: 'a890e240-b9fb-11ec-8598-338317271cf4', + type_id: 'siem.queryRule', + }, + ], + space_ids: ['default'], + task: { + scheduled: '2022-04-21T02:00:53.325Z', + schedule_delay: 2072000000, + }, + server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', + version: '8.3.0', + }, + rule: { + id: 'a890e240-b9fb-11ec-8598-338317271cf4', + license: 'basic', + category: 'siem.queryRule', + ruleset: 'siem', + }, + message: 'rule execution start: "a890e240-b9fb-11ec-8598-338317271cf4"', + ecs: { + version: '1.8.0', + }, + }, +]; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 2087e0d6ab523..546c23e080ded 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -1542,8 +1542,9 @@ export const indexEventLogExecutionEvents = async ( log: ToolingLog, events: object[] ): Promise => { + const aliases = await es.cat.aliases({ format: 'json', name: '.kibana-event-log-*' }); const operations = events.flatMap((doc: object) => [ - { index: { _index: '.kibana-event-log-*' } }, + { index: { _index: aliases[0].index } }, doc, ]);