From 4392ee80bcaf9753897e0fd81ab3fc63019851c4 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 11 Jun 2024 21:12:29 +0200 Subject: [PATCH] Manual rule run from rule details and rules table (#9327) (#184500) ## Summary Main ticket https://github.com/elastic/security-team/issues/9327 With this changes we introduce the way to schedule rule run manually. There are two ways to do that in UI: 1. Via "All actions" button on rules management page 2. Via "All actions" button on rule's details page **NOTES**: 1. To be able to test these changes, you need to enable feature flag `manualRuleRunEnabled` first 2. Bulk action will be part of a separate ticket/PR **RECORDING**: https://github.com/elastic/kibana/assets/2700761/d49bad53-026e-49c2-aeea-481203260b23 ### 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] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] https://github.com/elastic/security-docs/issues/5264 - [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] [Cypress RM (100 ESS & 100 Serverless)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6263) - [ ] [Cypress DE (100 ESS & 100 Serverless)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6280) - [x] [Integration Rule Gaps (100 ESS & 100 Serverless)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6257) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Ryland Herrick --- .buildkite/ftr_configs.yml | 2 + x-pack/plugins/alerting/common/index.ts | 2 + .../public/common/lib/apm/user_actions.ts | 1 + .../pages/rule_details/index.tsx | 13 + .../rule_gaps/api/__mocks__/api.ts | 17 + .../rule_gaps/api/api.test.ts | 44 +++ .../detection_engine/rule_gaps/api/api.ts | 34 +- .../use_schedule_rule_run_mutation.test.tsx | 52 +++ .../hooks/use_schedule_rule_run_mutation.ts | 25 ++ .../components/manual_rule_run/index.test.tsx | 78 ++++ .../components/manual_rule_run/index.tsx | 151 ++++++++ .../manual_rule_run/translations.ts | 63 ++++ .../use_manual_rule_run_confirmation.ts | 55 +++ .../rule_gaps/logic/__mocks__/mock.ts | 116 ++++++ .../logic/use_schedule_rule_run.test.tsx | 43 +++ .../rule_gaps/logic/use_schedule_rule_run.ts | 33 ++ .../rule_gaps/translations.ts | 17 + .../detection_engine/rule_gaps/types.ts | 10 + .../components/rules_table/rules_tables.tsx | 14 + .../components/rules_table/use_columns.tsx | 8 + .../rules_table/use_rules_table_actions.tsx | 31 ++ .../rule_actions_overflow/index.test.tsx | 64 ++++ .../rules/rule_actions_overflow/index.tsx | 36 ++ .../detection_engine/rules/translations.ts | 7 + .../package.json | 7 + .../configs/ess.config.ts | 22 ++ .../configs/serverless.config.ts | 21 ++ .../trial_license_complete_tier/index.ts | 14 + .../manual_rule_run.ts | 352 ++++++++++++++++++ .../detections_response/utils/rules/index.ts | 1 + .../utils/rules/rule_gaps.ts | 40 ++ .../test/security_solution_cypress/config.ts | 1 + .../rule_gaps/manual_rule_run.cy.ts | 49 +++ .../rule_details/execution_log.cy.ts | 2 +- .../cypress/screens/alerts_detection_rules.ts | 4 + .../cypress/tasks/alerts_detection_rules.ts | 17 + .../serverless_config.ts | 1 + 37 files changed, 1445 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/__mocks__/api.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.test.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/__mocks__/mock.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/ess.config.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/index.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/manual_rule_run.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/rule_gaps.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index c6e3242831167..76a0bd83613f9 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -491,6 +491,8 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/trial_license_complete_tier/configs/ess.config.ts diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 7a23d37511bbe..23d2d48ab5e5b 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -95,6 +95,8 @@ export const INTERNAL_ALERTING_BACKFILL_API_PATH = `${INTERNAL_BASE_ALERTING_API_PATH}/rules/backfill` as const; export const INTERNAL_ALERTING_BACKFILL_FIND_API_PATH = `${INTERNAL_ALERTING_BACKFILL_API_PATH}/_find` as const; +export const INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH = + `${INTERNAL_ALERTING_BACKFILL_API_PATH}/_schedule` as const; export const ALERTING_FEATURE_ID = 'alerts'; export const MONITORING_HISTORY_LIMIT = 200; diff --git a/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts b/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts index 64c11e06ae8d1..7293c8146fd18 100644 --- a/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts +++ b/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts @@ -13,6 +13,7 @@ export const SINGLE_RULE_ACTIONS = { DUPLICATE: `${APP_UI_ID} singleRuleActions duplicate`, EXPORT: `${APP_UI_ID} singleRuleActions export`, DELETE: `${APP_UI_ID} singleRuleActions delete`, + MANUAL_RULE_RUN: `${APP_UI_ID} singleRuleActions manual run`, PREVIEW: `${APP_UI_ID} singleRuleActions preview`, SAVE: `${APP_UI_ID} singleRuleActions save`, }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 18cde922f9b9d..43491a1969fff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -138,6 +138,8 @@ import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze import { useBoolState } from '../../../../common/hooks/use_bool_state'; import { RuleDefinitionSection } from '../../../rule_management/components/rule_details/rule_definition_section'; import { RuleScheduleSection } from '../../../rule_management/components/rule_details/rule_schedule_section'; +import { ManualRuleRunModal } from '../../../rule_gaps/components/manual_rule_run'; +import { useManualRuleRunConfirmation } from '../../../rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation'; // eslint-disable-next-line no-restricted-imports import { useLegacyUrlRedirect } from './use_redirect_legacy_url'; import { RuleDetailTabs, useRuleDetailsTabs } from './use_rule_details_tabs'; @@ -516,6 +518,13 @@ const RuleDetailsPageComponent: React.FC = ({ confirmRuleDuplication, } = useBulkDuplicateExceptionsConfirmation(); + const { + isManualRuleRunConfirmationVisible, + showManualRuleRunConfirmation, + cancelManualRuleRun, + confirmManualRuleRun, + } = useManualRuleRunConfirmation(); + if ( redirectToDetections( isSignalIndexExists, @@ -563,6 +572,9 @@ const RuleDetailsPageComponent: React.FC = ({ {i18n.DELETE_CONFIRMATION_BODY} )} + {isManualRuleRunConfirmationVisible && ( + + )} @@ -650,6 +662,7 @@ const RuleDetailsPageComponent: React.FC = ({ hasActionsPrivileges )} showBulkDuplicateExceptionsConfirmation={showBulkDuplicateConfirmation} + showManualRuleRunConfirmation={showManualRuleRunConfirmation} confirmDeletion={confirmDeletion} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/__mocks__/api.ts new file mode 100644 index 0000000000000..111a1c8e3ed79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/__mocks__/api.ts @@ -0,0 +1,17 @@ +/* + * 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 type { ScheduleBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/schedule'; +import { scheduleRuleRunMock } from '../../logic/__mocks__/mock'; + +import type { ScheduleBackfillProps } from '../../types'; + +export const scheduleRuleRun = async ({ + ruleIds, + timeRange, +}: ScheduleBackfillProps): Promise => + Promise.resolve(scheduleRuleRunMock); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.test.ts new file mode 100644 index 0000000000000..ceff24c730bfd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.test.ts @@ -0,0 +1,44 @@ +/* + * 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 moment from 'moment'; + +import { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; + +import { KibanaServices } from '../../../common/lib/kibana'; +import { scheduleRuleRunMock } from '../logic/__mocks__/mock'; +import { scheduleRuleRun } from './api'; + +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../../common/lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('Detections Rule Gaps API', () => { + describe('scheduleRuleRun', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(scheduleRuleRunMock); + }); + + test('schedules rule run', async () => { + const timeRange = { startDate: moment().subtract(1, 'd'), endDate: moment() }; + await scheduleRuleRun({ + ruleIds: ['rule-1'], + timeRange, + }); + expect(fetchMock).toHaveBeenCalledWith( + INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, + expect.objectContaining({ + body: `[{"rule_id":"rule-1","start":"${timeRange.startDate.toISOString()}","end":"${timeRange.endDate.toISOString()}"}]`, + method: 'POST', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts index 2ea5453e1733f..ef00e3e9e4e78 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts @@ -6,11 +6,43 @@ */ import { - INTERNAL_ALERTING_BACKFILL_FIND_API_PATH, INTERNAL_ALERTING_BACKFILL_API_PATH, + INTERNAL_ALERTING_BACKFILL_FIND_API_PATH, + INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, } from '@kbn/alerting-plugin/common'; import type { FindBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/find'; +import type { ScheduleBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/schedule'; import { KibanaServices } from '../../../common/lib/kibana'; +import type { ScheduleBackfillProps } from '../types'; + +/** + * Schedule rules run over a specified time range + * + * @param ruleIds `rule_id`s of each rule to be backfilled + * @param timeRange the time range over which the backfill should apply + * + * @throws An error if response is not OK + */ +export const scheduleRuleRun = async ({ + ruleIds, + timeRange, +}: ScheduleBackfillProps): Promise => { + const params = ruleIds.map((ruleId) => { + return { + rule_id: ruleId, + start: timeRange.startDate.toISOString(), + end: timeRange.endDate.toISOString(), + }; + }); + return KibanaServices.get().http.fetch( + INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, + { + method: 'POST', + version: '2023-10-31', + body: JSON.stringify(params), + } + ); +}; /** * Find backfills for the given rule IDs diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.test.tsx new file mode 100644 index 0000000000000..6c641ceb2ad9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 moment from 'moment'; + +import { act } from '@testing-library/react-hooks'; +import { useScheduleRuleRunMutation } from './use_schedule_rule_run_mutation'; +import { renderMutation } from '../../../../management/hooks/test_utils'; +import { scheduleRuleRunMock } from '../../logic/__mocks__/mock'; +import { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; + +import { KibanaServices } from '../../../../common/lib/kibana'; + +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../../../common/lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +const apiVersion = '2023-10-31'; + +describe('Schedule rule run hook', () => { + let result: ReturnType; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(scheduleRuleRunMock); + }); + + it('schedules a rule run by calling the backfill API', async () => { + result = await renderMutation(() => useScheduleRuleRunMutation()); + + expect(fetchMock).toHaveBeenCalledTimes(0); + + const timeRange = { startDate: moment().subtract(1, 'd'), endDate: moment() }; + + await act(async () => { + const res = await result.mutateAsync({ ruleIds: ['rule-1'], timeRange }); + expect(res).toEqual(scheduleRuleRunMock); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, { + body: `[{"rule_id":"rule-1","start":"${timeRange.startDate.toISOString()}","end":"${timeRange.endDate.toISOString()}"}]`, + method: 'POST', + version: apiVersion, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.ts new file mode 100644 index 0000000000000..78e3c5cbe6ca5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_schedule_rule_run_mutation.ts @@ -0,0 +1,25 @@ +/* + * 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 { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import type { ScheduleBackfillProps } from '../../types'; +import { scheduleRuleRun } from '../api'; + +export const SCHEDULE_RULE_RUN_MUTATION_KEY = [ + 'POST', + INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, +]; + +export const useScheduleRuleRunMutation = ( + options?: UseMutationOptions +) => { + return useMutation((scheduleOptions: ScheduleBackfillProps) => scheduleRuleRun(scheduleOptions), { + ...options, + mutationKey: SCHEDULE_RULE_RUN_MUTATION_KEY, + }); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.test.tsx new file mode 100644 index 0000000000000..a215de5406080 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, within } from '@testing-library/react'; +import { ManualRuleRunModal } from '.'; + +describe('ManualRuleRunModal', () => { + const onCancelMock = jest.fn(); + const onConfirmMock = jest.fn(); + + afterEach(() => { + onCancelMock.mockReset(); + onConfirmMock.mockReset(); + }); + + it('should render modal', () => { + const wrapper = render( + + ); + + expect(wrapper.getByTestId('manual-rule-run-modal-form')).toBeInTheDocument(); + expect(wrapper.getByTestId('confirmModalCancelButton')).toBeEnabled(); + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + }); + + it('should render confirmation button disabled if invalid time range has been selected', () => { + const wrapper = render( + + ); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + + within(wrapper.getByTestId('end-date-picker')).getByText('Previous Month').click(); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeDisabled(); + expect(wrapper.getByTestId('manual-rule-run-time-range-form')).toHaveTextContent( + 'Selected time range is invalid' + ); + }); + + it('should render confirmation button disabled if selected start date is more than 90 days in the past', () => { + const wrapper = render( + + ); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + + within(wrapper.getByTestId('start-date-picker')).getByText('Previous Month').click(); + within(wrapper.getByTestId('start-date-picker')).getByText('Previous Month').click(); + within(wrapper.getByTestId('start-date-picker')).getByText('Previous Month').click(); + within(wrapper.getByTestId('start-date-picker')).getByText('Previous Month').click(); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeDisabled(); + expect(wrapper.getByTestId('manual-rule-run-time-range-form')).toHaveTextContent( + 'Manual rule run cannot be scheduled earlier than 90 days ago' + ); + }); + + it('should render confirmation button disabled if selected end date is in future', () => { + const wrapper = render( + + ); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + + within(wrapper.getByTestId('end-date-picker')).getByText('Next month').click(); + + expect(wrapper.getByTestId('confirmModalConfirmButton')).toBeDisabled(); + expect(wrapper.getByTestId('manual-rule-run-time-range-form')).toHaveTextContent( + 'Manual rule run cannot be scheduled for the future' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.tsx new file mode 100644 index 0000000000000..365ebc865ec32 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/index.tsx @@ -0,0 +1,151 @@ +/* + * 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 { + EuiBetaBadge, + EuiConfirmModal, + EuiDatePicker, + EuiDatePickerRange, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + useGeneratedHtmlId, +} from '@elastic/eui'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../../../common/translations'; + +import * as i18n from './translations'; + +export const MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS = 90; + +interface ManualRuleRunModalProps { + onCancel: () => void; + onConfirm: (timeRange: { startDate: moment.Moment; endDate: moment.Moment }) => void; +} + +const ManualRuleRunModalComponent = ({ onCancel, onConfirm }: ManualRuleRunModalProps) => { + const modalTitleId = useGeneratedHtmlId(); + + const now = moment(); + + // By default we show three hours time range which user can then adjust + const [startDate, setStartDate] = useState(now.clone().subtract(3, 'h')); + const [endDate, setEndDate] = useState(now.clone()); + + const isStartDateOutOfRange = now + .clone() + .subtract(MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS, 'd') + .isAfter(startDate); + const isEndDateInFuture = endDate.isAfter(now); + const isInvalidTimeRange = startDate.isSameOrAfter(endDate); + const isInvalid = isStartDateOutOfRange || isEndDateInFuture || isInvalidTimeRange; + const errorMessage = useMemo(() => { + if (isStartDateOutOfRange) { + return i18n.MANUAL_RULE_RUN_START_DATE_OUT_OF_RANGE_ERROR( + MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS + ); + } + if (isEndDateInFuture) { + return i18n.MANUAL_RULE_RUN_FUTURE_TIME_RANGE_ERROR; + } + if (isInvalidTimeRange) { + return i18n.MANUAL_RULE_RUN_INVALID_TIME_RANGE_ERROR; + } + return null; + }, [isEndDateInFuture, isInvalidTimeRange, isStartDateOutOfRange]); + + const handleConfirm = useCallback(() => { + onConfirm({ startDate, endDate }); + }, [endDate, onConfirm, startDate]); + + return ( + + + + + {i18n.MANUAL_RULE_RUN_TIME_RANGE_TITLE} + + + + + } + isInvalid={isInvalid} + error={errorMessage} + > + date && setStartDate(date)} + startDate={startDate} + endDate={endDate} + showTimeSelect={true} + /> + } + endDateControl={ + date && setEndDate(date)} + startDate={startDate} + endDate={endDate} + showTimeSelect={true} + /> + } + /> + + + + date && setStartDate(date)} + startDate={startDate} + endDate={endDate} + showTimeSelect={true} + /> + + + + date && setEndDate(date)} + startDate={startDate} + endDate={endDate} + showTimeSelect={true} + /> + + + + ); +}; + +export const ManualRuleRunModal = React.memo(ManualRuleRunModalComponent); +ManualRuleRunModal.displayName = 'ManualRuleRunModal'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/translations.ts new file mode 100644 index 0000000000000..c640377ed3b23 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/translations.ts @@ -0,0 +1,63 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const MANUAL_RULE_RUN_TIME_RANGE_TITLE = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.timeRangeTitle', + { + defaultMessage: 'Select timerange for manual rule run', + } +); + +export const MANUAL_RULE_RUN_START_AT_TITLE = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.startAtTitle', + { + defaultMessage: 'Start at', + } +); + +export const MANUAL_RULE_RUN_END_AT_TITLE = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.endAtTitle', + { + defaultMessage: 'Finish at', + } +); + +export const MANUAL_RULE_RUN_CONFIRM_BUTTON = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.confirmButton', + { + defaultMessage: 'Run', + } +); + +export const MANUAL_RULE_RUN_CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.cancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const MANUAL_RULE_RUN_INVALID_TIME_RANGE_ERROR = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.invalidTimeRangeError', + { + defaultMessage: 'Selected time range is invalid', + } +); + +export const MANUAL_RULE_RUN_FUTURE_TIME_RANGE_ERROR = i18n.translate( + 'xpack.securitySolution.manuelRuleRun.futureTimeRangeError', + { + defaultMessage: 'Manual rule run cannot be scheduled for the future', + } +); + +export const MANUAL_RULE_RUN_START_DATE_OUT_OF_RANGE_ERROR = (maxDaysLookback: number) => + i18n.translate('xpack.securitySolution.manuelRuleRun.startDateIsOutOfRangeError', { + values: { maxDaysLookback }, + defaultMessage: + 'Manual rule run cannot be scheduled earlier than {maxDaysLookback, plural, =1 {# day} other {# days}} ago', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation.ts new file mode 100644 index 0000000000000..160a5780e2b89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation.ts @@ -0,0 +1,55 @@ +/* + * 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 { useCallback, useRef } from 'react'; + +import { useBoolState } from '../../../../common/hooks/use_bool_state'; +import type { TimeRange } from '../../types'; + +/** + * Hook that controls manual rule run confirmation modal window and its content + */ +export const useManualRuleRunConfirmation = () => { + const [isManualRuleRunConfirmationVisible, showModal, hideModal] = useBoolState(); + const confirmationPromiseRef = useRef<(timerange: TimeRange | null) => void>(); + + const onConfirm = useCallback((timerange: TimeRange) => { + confirmationPromiseRef.current?.(timerange); + }, []); + + const onCancel = useCallback(() => { + confirmationPromiseRef.current?.(null); + }, []); + + const initModal = useCallback(() => { + showModal(); + + return new Promise((resolve) => { + confirmationPromiseRef.current = resolve; + }).finally(() => { + hideModal(); + }); + }, [showModal, hideModal]); + + const showManualRuleRunConfirmation = useCallback(async () => { + const confirmation = await initModal(); + if (confirmation) { + onConfirm(confirmation); + } else { + onCancel(); + } + + return confirmation; + }, [initModal, onConfirm, onCancel]); + + return { + isManualRuleRunConfirmationVisible, + showManualRuleRunConfirmation, + cancelManualRuleRun: onCancel, + confirmManualRuleRun: onConfirm, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/__mocks__/mock.ts new file mode 100644 index 0000000000000..fc6c15323ffbf --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/__mocks__/mock.ts @@ -0,0 +1,116 @@ +/* + * 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 type { ScheduleBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/schedule'; + +export const scheduleRuleRunMock: ScheduleBackfillResponseBody = [ + { + id: '2d0deaa0-6263-4271-9838-ad0a28facaf0', + duration: '5m', + enabled: true, + end: '2024-05-28T14:30:00.000Z', + start: '2024-05-28T14:00:00.000Z', + status: 'pending', + created_at: '2024-05-28T14:53:14.193Z', + space_id: 'default', + rule: { + name: 'Rule 2', + tags: [], + params: { + author: [], + description: 'asdasd', + falsePositives: [], + from: 'now-360s', + ruleId: 'c2db9040-2398-4d4c-a683-9ea6478340a6', + investigationFields: { + field_names: ['event.category', 'blablabla'], + }, + immutable: false, + license: '', + outputIndex: '', + meta: { + from: '1m', + kibana_siem_app_url: 'http://localhost:5601/sbb/app/security', + }, + maxSignals: 100, + riskScore: 21, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 3, + exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', + type: 'query', + language: 'kuery', + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + '-*elastic-cloud-logs-*', + ], + query: '*', + filters: [], + }, + consumer: 'siem', + enabled: true, + schedule: { + interval: '5m', + }, + revision: 2, + id: 'b04c2714-1bd2-4925-a2b0-8dddc320c41e', + rule_type_id: 'siem.queryRule', + api_key_owner: 'elastic', + api_key_created_by_user: false, + created_by: 'elastic', + created_at: '2024-05-27T09:41:09.269Z', + updated_by: 'elastic', + updated_at: '2024-05-28T08:44:06.275Z', + }, + schedule: [ + { + run_at: '2024-05-28T14:05:00.000Z', + status: 'pending', + interval: '5m', + }, + { + run_at: '2024-05-28T14:10:00.000Z', + status: 'pending', + interval: '5m', + }, + { + run_at: '2024-05-28T14:15:00.000Z', + status: 'pending', + interval: '5m', + }, + { + run_at: '2024-05-28T14:20:00.000Z', + status: 'pending', + interval: '5m', + }, + { + run_at: '2024-05-28T14:25:00.000Z', + status: 'pending', + interval: '5m', + }, + { + run_at: '2024-05-28T14:30:00.000Z', + status: 'pending', + interval: '5m', + }, + ], + }, +]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx new file mode 100644 index 0000000000000..94c3f7e36acdb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; +import { act, renderHook } from '@testing-library/react-hooks'; +import moment from 'moment'; +import { useKibana } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { useScheduleRuleRun } from './use_schedule_rule_run'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.MockedFunction; + +describe('When using the `useScheduleRuleRun()` hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should send schedule rule run request', async () => { + const { result, waitFor } = renderHook(() => useScheduleRuleRun(), { + wrapper: TestProviders, + }); + + const timeRange = { startDate: moment().subtract(1, 'd'), endDate: moment() }; + act(() => { + result.current.scheduleRuleRun({ ruleIds: ['rule-1'], timeRange }); + }); + + await waitFor(() => (useKibanaMock().services.http.fetch as jest.Mock).mock.calls.length > 0); + + expect(useKibanaMock().services.http.fetch).toHaveBeenCalledWith( + INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, + expect.objectContaining({ + body: `[{"rule_id":"rule-1","start":"${timeRange.startDate.toISOString()}","end":"${timeRange.endDate.toISOString()}"}]`, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts new file mode 100644 index 0000000000000..7c00c4294acdc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts @@ -0,0 +1,33 @@ +/* + * 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 { useCallback } from 'react'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useScheduleRuleRunMutation } from '../api/hooks/use_schedule_rule_run_mutation'; +import type { ScheduleBackfillProps } from '../types'; + +import * as i18n from '../translations'; + +export function useScheduleRuleRun() { + const { mutateAsync } = useScheduleRuleRunMutation(); + const { addError, addSuccess } = useAppToasts(); + + const scheduleRuleRun = useCallback( + async (options: ScheduleBackfillProps) => { + try { + const results = await mutateAsync(options); + addSuccess(i18n.BACKFILL_SCHEDULE_SUCCESS(results.length)); + return results; + } catch (error) { + addError(error, { title: i18n.BACKFILL_SCHEDULE_ERROR_TITLE }); + } + }, + [addError, addSuccess, mutateAsync] + ); + + return { scheduleRuleRun }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts index aec5e579259ef..cb77ec89524fc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts @@ -125,3 +125,20 @@ export const BACKFILL_TABLE_SUBTITLE = i18n.translate( defaultMessage: 'View and manage backfill runs', } ); + +export const BACKFILL_SCHEDULE_SUCCESS = (numRules: number) => + i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.backfillSchedule.scheduleRuleRunSuccessTitle', + { + values: { numRules }, + defaultMessage: + 'Successfully scheduled backfill for {numRules, plural, =1 {# rule} other {# rules}}', + } + ); + +export const BACKFILL_SCHEDULE_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.backfillSchedule.scheduleRuleRunErrorTitle', + { + defaultMessage: 'Error while scheduling backfill', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/types.ts index 285e62fab05f1..03b9ed4a4bea0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/types.ts @@ -21,3 +21,13 @@ export interface BackfillStats { } export type BackfillRow = Backfill & BackfillStats; + +export interface TimeRange { + startDate: moment.Moment; + endDate: moment.Moment; +} + +export interface ScheduleBackfillProps { + ruleIds: string[]; + timeRange: TimeRange; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx index 4dfac4c0c2c39..d5e3fb77b237d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx @@ -35,6 +35,8 @@ import { RULES_TABLE_PAGE_SIZE_OPTIONS } from './constants'; import { useRuleManagementFilters } from '../../../rule_management/logic/use_rule_management_filters'; import type { FindRulesSortField } from '../../../../../common/api/detection_engine/rule_management'; import { useIsUpgradingSecurityPackages } from '../../../rule_management/logic/use_upgrade_security_packages'; +import { useManualRuleRunConfirmation } from '../../../rule_gaps/components/manual_rule_run/use_manual_rule_run_confirmation'; +import { ManualRuleRunModal } from '../../../rule_gaps/components/manual_rule_run'; const INITIAL_SORT_FIELD = 'enabled'; @@ -108,6 +110,13 @@ export const RulesTables = React.memo(({ selectedTab }) => { confirmRuleDuplication, } = useBulkDuplicateExceptionsConfirmation(); + const { + isManualRuleRunConfirmationVisible, + showManualRuleRunConfirmation, + cancelManualRuleRun, + confirmManualRuleRun, + } = useManualRuleRunConfirmation(); + const { bulkEditActionType, isBulkEditFlyoutVisible, @@ -155,6 +164,7 @@ export const RulesTables = React.memo(({ selectedTab }) => { mlJobs, startMlJobs, showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }); @@ -164,6 +174,7 @@ export const RulesTables = React.memo(({ selectedTab }) => { mlJobs, startMlJobs, showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }); @@ -262,6 +273,9 @@ export const RulesTables = React.memo(({ selectedTab }) => { /> )} + {isManualRuleRunConfirmationVisible && ( + + )} {isBulkActionConfirmationVisible && bulkAction && ( | EuiTableActionsColumnType; @@ -57,6 +58,7 @@ interface ColumnsProps { interface ActionColumnsProps { showExceptionsDuplicateConfirmation: () => Promise; + showManualRuleRunConfirmation: () => Promise; confirmDeletion: () => Promise; } @@ -233,10 +235,12 @@ const INTEGRATIONS_COLUMN: TableColumn = { const useActionsColumn = ({ showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }: ActionColumnsProps): EuiTableActionsColumnType => { const actions = useRulesTableActions({ showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }); @@ -251,10 +255,12 @@ export const useRulesColumns = ({ mlJobs, startMlJobs, showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }: UseColumnsProps): TableColumn[] => { const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }); const ruleNameColumn = useRuleNameColumn(); @@ -363,11 +369,13 @@ export const useMonitoringColumns = ({ mlJobs, startMlJobs, showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }: UseColumnsProps): TableColumn[] => { const docLinks = useKibana().services.docLinks; const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }); const ruleNameColumn = useRuleNameColumn(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx index 04fc59da5e027..dc4a0cb429b87 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx @@ -8,6 +8,7 @@ import type { DefaultItemAction } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import React from 'react'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionTypeEnum } from '../../../../../common/api/detection_engine/rule_management'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; @@ -23,12 +24,16 @@ import { } from '../../../rule_management/logic/bulk_actions/use_execute_bulk_action'; import { useDownloadExportedRules } from '../../../rule_management/logic/bulk_actions/use_download_exported_rules'; import { useHasActionsPrivileges } from './use_has_actions_privileges'; +import type { TimeRange } from '../../../rule_gaps/types'; +import { useScheduleRuleRun } from '../../../rule_gaps/logic/use_schedule_rule_run'; export const useRulesTableActions = ({ showExceptionsDuplicateConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }: { showExceptionsDuplicateConfirmation: () => Promise; + showManualRuleRunConfirmation: () => Promise; confirmDeletion: () => Promise; }): Array> => { const { navigateToApp } = useKibana().services.application; @@ -37,6 +42,9 @@ export const useRulesTableActions = ({ const { executeBulkAction } = useExecuteBulkAction(); const { bulkExport } = useBulkExport(); const downloadExportedRules = useDownloadExportedRules(); + const { scheduleRuleRun } = useScheduleRuleRun(); + + const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); return [ { @@ -109,6 +117,29 @@ export const useRulesTableActions = ({ }, enabled: (rule: Rule) => !rule.immutable, }, + ...(isManualRuleRunEnabled + ? [ + { + type: 'icon', + 'data-test-subj': 'manualRuleRunAction', + description: i18n.MANUAL_RULE_RUN, + icon: 'play', + name: i18n.MANUAL_RULE_RUN, + onClick: async (rule: Rule) => { + startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); + const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + if (modalManualRuleRunConfirmationResult === null) { + return; + } + await scheduleRuleRun({ + ruleIds: [rule.id], + timeRange: modalManualRuleRunConfirmationResult, + }); + }, + enabled: (rule: Rule) => rule.enabled, + } as DefaultItemAction, + ] + : []), { type: 'icon', 'data-test-subj': 'deleteRuleAction', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 1221d855d5aa9..43a9246d8d5c9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -10,17 +10,22 @@ import React from 'react'; import { useBulkExport } from '../../../../detection_engine/rule_management/logic/bulk_actions/use_bulk_export'; import { useExecuteBulkAction } from '../../../../detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action'; +import { useScheduleRuleRun } from '../../../../detection_engine/rule_gaps/logic/use_schedule_rule_run'; import { RuleActionsOverflow } from '.'; import { mockRule } from '../../../../detection_engine/rule_management_ui/components/rules_table/__mocks__/mock'; import { TestProviders } from '../../../../common/mock'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; const showBulkDuplicateExceptionsConfirmation = () => Promise.resolve(null); +const showManualRuleRunConfirmation = () => Promise.resolve(null); +jest.mock('../../../../common/hooks/use_experimental_features'); jest.mock( '../../../../detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action' ); jest.mock('../../../../detection_engine/rule_management/logic/bulk_actions/use_bulk_export'); +jest.mock('../../../../detection_engine/rule_gaps/logic/use_schedule_rule_run'); jest.mock('../../../../common/lib/apm/use_start_transaction'); jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/lib/kibana', () => { @@ -39,20 +44,30 @@ jest.mock('../../../../common/lib/kibana', () => { const useExecuteBulkActionMock = useExecuteBulkAction as jest.Mock; const useBulkExportMock = useBulkExport as jest.Mock; +const useScheduleRuleRunMock = useScheduleRuleRun as jest.Mock; + +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; describe('RuleActionsOverflow', () => { + const scheduleRuleRun = jest.fn(); + afterEach(() => { jest.clearAllMocks(); }); afterAll(() => { jest.clearAllMocks(); }); + beforeEach(() => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + useScheduleRuleRunMock.mockReturnValue({ scheduleRuleRun }); + }); describe('rules details menu panel', () => { test('menu items rendered when a rule is passed to the component', () => { const { getByTestId } = render( { expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Duplicate rule'); expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Export rule'); expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Delete rule'); + expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Manual run'); }); test('menu is empty when no rule is passed to the component', () => { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { }); }); }); + + describe('rules details manual rule run', () => { + test('it closes the popover when rules-details-manual-rule-run is clicked', () => { + const { getByTestId } = render( + Promise.resolve(true)} + />, + { wrapper: TestProviders } + ); + fireEvent.click(getByTestId('rules-details-popover-button-icon')); + fireEvent.click(getByTestId('rules-details-manual-rule-run')); + + // Popover is not shown + expect(getByTestId('rules-details-popover')).not.toHaveTextContent(/.+/); + }); + + test('it does not show "Manual run" action item when feature flag "manualRuleRunEnabled" is set to false', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + const { getByTestId } = render( + Promise.resolve(true)} + />, + { wrapper: TestProviders } + ); + fireEvent.click(getByTestId('rules-details-popover-button-icon')); + + expect(getByTestId('rules-details-menu-panel')).not.toHaveTextContent('Manual run'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index 9a351af0803c7..5be000d508195 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -14,6 +14,9 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useScheduleRuleRun } from '../../../../detection_engine/rule_gaps/logic/use_schedule_rule_run'; +import type { TimeRange } from '../../../../detection_engine/rule_gaps/types'; import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionTypeEnum } from '../../../../../common/api/detection_engine/rule_management'; @@ -49,6 +52,7 @@ interface RuleActionsOverflowComponentProps { userHasPermissions: boolean; canDuplicateRuleWithActions: boolean; showBulkDuplicateExceptionsConfirmation: () => Promise; + showManualRuleRunConfirmation: () => Promise; confirmDeletion: () => Promise; } @@ -60,6 +64,7 @@ const RuleActionsOverflowComponent = ({ userHasPermissions, canDuplicateRuleWithActions, showBulkDuplicateExceptionsConfirmation, + showManualRuleRunConfirmation, confirmDeletion, }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); @@ -68,6 +73,9 @@ const RuleActionsOverflowComponent = ({ const { executeBulkAction } = useExecuteBulkAction({ suppressSuccessToast: true }); const { bulkExport } = useBulkExport(); const downloadExportedRules = useDownloadExportedRules(); + const { scheduleRuleRun } = useScheduleRuleRun(); + + const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); const onRuleDeletedCallback = useCallback(() => { navigateToApp(APP_UI_ID, { @@ -141,6 +149,31 @@ const RuleActionsOverflowComponent = ({ > {i18nActions.EXPORT_RULE} , + ...(isManualRuleRunEnabled + ? [ + { + startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); + closePopover(); + const modalManualRuleRunConfirmationResult = + await showManualRuleRunConfirmation(); + if (modalManualRuleRunConfirmationResult === null) { + return; + } + await scheduleRuleRun({ + ruleIds: [rule.id], + timeRange: modalManualRuleRunConfirmationResult, + }); + }} + > + {i18nActions.MANUAL_RULE_RUN} + , + ] + : []), { + const schedule = []; + const interval = `${intervalInMinutes}m`; + let currentDate = startDate.clone(); + while (currentDate.isBefore(endDate)) { + schedule.push({ + interval, + run_at: currentDate.add(intervalInMinutes, 'm').toISOString(), + status: 'pending', + }); + currentDate = currentDate.clone(); + } + return schedule; +}; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const log = getService('log'); + const es = getService('es'); + + // Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. + // Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well + describe('@ess @serverless @skipInServerlessMKI manual_rule_run', () => { + beforeEach(async () => { + await createAlertsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + }); + + describe('happy path', () => { + it('should schedule rule run over valid time range', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: true, + interval, + }) + ); + + const endDate = moment(); + const startDate = endDate.clone().subtract(1, 'h'); + + const results = await scheduleRuleRun(supertest, [createdRule.id], { + startDate, + endDate, + }); + + expect(results.length).toBe(1); + expect((results[0] as BackfillResponse).start).toEqual(startDate.toISOString()); + expect((results[0] as BackfillResponse).schedule).toEqual( + buildSchedule(startDate, endDate, intervalInMinutes) + ); + }); + }); + + describe('error handling', () => { + it('should return bad request error when rule is disabled', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + + const endDate = moment(); + const startDate = endDate.clone().subtract(1, 'h'); + + const results = await scheduleRuleRun(supertest, [createdRule.id], { + startDate, + endDate, + }); + + expect(results).toEqual([ + { + error: { + error: 'Bad Request', + message: `Rule ${createdRule.id} is disabled`, + }, + }, + ]); + }); + + it('should return bad request error when rule ID does not exist', async () => { + const endDate = moment(); + const startDate = endDate.clone().subtract(1, 'h'); + + const nonExistingRuleId = 'non-existing-rule-id'; + const results = await scheduleRuleRun( + supertest, + [nonExistingRuleId], + { + startDate, + endDate, + }, + 400 + ); + + expect(results).toEqual({ + error: 'Bad Request', + message: `No rules matching ids ${nonExistingRuleId} found to schedule backfill`, + statusCode: 400, + }); + }); + + it('should return bad request error when start date greater than end date', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + + const startDate = moment(); + const endDate = startDate.clone().subtract(1, 'h'); + + const results = await scheduleRuleRun( + supertest, + [createdRule.id], + { + startDate, + endDate, + }, + 400 + ); + + expect(results).toEqual({ + error: 'Bad Request', + message: '[request body.0]: Backfill end must be greater than backfill start', + statusCode: 400, + }); + }); + + it('should return bad request error when start date is equal to end date', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + + const startDate = moment(); + const endDate = startDate.clone(); + + const results = await scheduleRuleRun( + supertest, + [createdRule.id], + { + startDate, + endDate, + }, + 400 + ); + + expect(results).toEqual({ + error: 'Bad Request', + message: '[request body.0]: Backfill end must be greater than backfill start', + statusCode: 400, + }); + }); + + it('should return bad request error when some of the rules do not exist', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: true, + interval, + }) + ); + + const endDate = moment(); + const startDate = endDate.clone().subtract(1, 'h'); + + const nonExistingRuleId = 'non-existing-rule-id'; + const results = await scheduleRuleRun(supertest, [createdRule.id, nonExistingRuleId], { + startDate, + endDate, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule: expect.objectContaining({ id: `${createdRule.id}` }), + schedule: buildSchedule(startDate, endDate, intervalInMinutes), + }), + { + error: { + error: 'Not Found', + message: `Saved object [alert/${nonExistingRuleId}] not found`, + }, + }, + ]) + ); + }); + + it('should return bad request error when some of the rules are disabled', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + const createdRule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + interval, + }) + ); + + const endDate = moment(); + const startDate = endDate.clone().subtract(1, 'h'); + + const results = await scheduleRuleRun(supertest, [createdRule1.id, createdRule2.id], { + startDate, + endDate, + }); + + expect(results).toEqual( + expect.arrayContaining([ + { + error: { + error: 'Bad Request', + message: `Rule ${createdRule1.id} is disabled`, + }, + }, + expect.objectContaining({ + rule: expect.objectContaining({ id: `${createdRule2.id}` }), + schedule: buildSchedule(startDate, endDate, intervalInMinutes), + }), + ]) + ); + }); + + it('should return bad request error when start date is more than 90 days in the past', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + + const endDate = moment(); + const startDate = endDate.clone().subtract(91, 'd'); + + const results = await scheduleRuleRun( + supertest, + [createdRule.id], + { + startDate, + endDate, + }, + 400 + ); + + expect(results).toEqual({ + error: 'Bad Request', + message: '[request body.0]: Backfill cannot look back more than 90 days', + statusCode: 400, + }); + }); + + it('should return bad request error when end date is in future', async () => { + const intervalInMinutes = 25; + const interval = `${intervalInMinutes}m`; + const createdRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval, + }) + ); + + const endDate = moment().add(30, 'm'); + const startDate = endDate.clone().subtract(1, 'h'); + + const results = await scheduleRuleRun( + supertest, + [createdRule.id], + { + startDate, + endDate, + }, + 400 + ); + + expect(results).toEqual({ + error: 'Bad Request', + message: '[request body.0]: Backfill cannot be scheduled for the future', + statusCode: 400, + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts index e69d515ce2e5d..4a68b5b7df2de 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts @@ -49,5 +49,6 @@ export * from './remove_server_generated_properties_including_rule_id'; export * from './rule_to_update_schema'; export * from './update_rule'; export * from './get_simple_rule_as_ndjson'; +export * from './rule_gaps'; export * from './prebuilt_rules'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/rule_gaps.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/rule_gaps.ts new file mode 100644 index 0000000000000..11edc7c50b03e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/rule_gaps.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type SuperTest from 'supertest'; + +import moment from 'moment'; +import { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; +import { ScheduleBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/schedule'; + +export interface TimeRange { + startDate: moment.Moment; + endDate: moment.Moment; +} + +export const scheduleRuleRun = async ( + supertest: SuperTest.Agent, + ruleIds: string[], + timeRange: TimeRange, + expectedStatusCode = 200 +): Promise => { + const params = ruleIds.map((ruleId) => { + return { + rule_id: ruleId, + start: timeRange.startDate.toISOString(), + end: timeRange.endDate.toISOString(), + }; + }); + const response = await supertest + .post(INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'Kibana') + .send(params) + .expect(expectedStatusCode); + return response.body; +}; diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 606df49c4f90e..6e65ab15324a6 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -47,6 +47,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertSuppressionForEsqlRuleEnabled', 'bulkCustomHighlightedFieldsEnabled', + 'manualRuleRunEnabled', ])}`, // mock cloud to enable the guided onboarding tour in e2e tests '--xpack.cloud.id=test', diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts new file mode 100644 index 0000000000000..28eaef22cc2e7 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts @@ -0,0 +1,49 @@ +/* + * 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 { TOASTER } from '../../../../screens/alerts_detection_rules'; +import { visitRulesManagementTable } from '../../../../tasks/rules_management'; +import { + disableAutoRefresh, + manuallyRunFirstRule, + manualRuleRunFromDetailsPage, +} from '../../../../tasks/alerts_detection_rules'; +import { visitRuleDetailsPage } from '../../../../tasks/rule_details'; +import { getNewRule } from '../../../../objects/rule'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { login } from '../../../../tasks/login'; + +// Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. +// Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well +describe('Manual rule run', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + }); + + it('schedule from rule details page', () => { + createRule(getNewRule({ rule_id: 'new custom rule', interval: '5m', from: 'now-6m' })).then( + (rule) => visitRuleDetailsPage(rule.body.id) + ); + manualRuleRunFromDetailsPage(); + + cy.get(TOASTER).should('have.text', 'Successfully scheduled backfill for 1 rule'); + }); + + it('schedule from rules management table', () => { + createRule(getNewRule({ rule_id: 'new custom rule', interval: '5m', from: 'now-6m' })).then( + (rule) => { + visitRulesManagementTable(); + disableAutoRefresh(); + manuallyRunFirstRule(); + + cy.get(TOASTER).should('have.text', 'Successfully scheduled backfill for 1 rule'); + } + ); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts index 219f61b655f5d..a34826d2c8cb4 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts @@ -61,7 +61,7 @@ describe.skip( cy.waitUntil( () => { - cy.log('Waiting for assignees to appear in popover'); + cy.log('Waiting for execution logs to appear in execution log table'); refreshRuleExecutionTable(); return getExecutionLogTableRow().then((rows) => { return rows.length === 2; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts index d364fb21c141d..403d4257687c2 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts @@ -26,6 +26,8 @@ export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; export const DUPLICATE_RULE_ACTION_BTN = '[data-test-subj="duplicateRuleAction"]'; +export const MANUAL_RULE_RUN_ACTION_BTN = '[data-test-subj="manualRuleRunAction"]'; + export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-duplicate-rule"]'; export const CONFIRM_DUPLICATE_RULE = '[data-test-subj="confirmModalConfirmButton"]'; @@ -125,6 +127,8 @@ export const MODAL_CONFIRMATION_CANCEL_BTN = '[data-test-subj="confirmModalCance export const RULE_DETAILS_DELETE_BTN = '[data-test-subj="rules-details-delete-rule"]'; +export const RULE_DETAILS_MANUAL_RULE_RUN_BTN = '[data-test-subj="rules-details-manual-rule-run"]'; + export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; export const SELECT_ALL_RULES_ON_PAGE_CHECKBOX = '[data-test-subj="checkboxSelectAll"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts index d5eeef7801d1b..97d6c34fdd040 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts @@ -55,6 +55,8 @@ import { CONFIRM_DELETE_RULE_BTN, AUTO_REFRESH_POPOVER_TRIGGER_BUTTON, SELECT_ALL_RULES_ON_PAGE_CHECKBOX, + RULE_DETAILS_MANUAL_RULE_RUN_BTN, + MANUAL_RULE_RUN_ACTION_BTN, } from '../screens/alerts_detection_rules'; import type { RULES_MONITORING_TABLE } from '../screens/alerts_detection_rules'; import { EUI_CHECKBOX } from '../screens/common/controls'; @@ -88,6 +90,14 @@ export const duplicateFirstRule = () => { cy.get(CONFIRM_DUPLICATE_RULE).click(); }; +export const manuallyRunFirstRule = () => { + cy.get(COLLAPSED_ACTION_BTN).should('be.visible'); + cy.get(COLLAPSED_ACTION_BTN).first().click(); + cy.get(MANUAL_RULE_RUN_ACTION_BTN).should('be.visible'); + cy.get(MANUAL_RULE_RUN_ACTION_BTN).click(); + cy.get(MODAL_CONFIRMATION_BTN).click(); +}; + /** * Duplicates the rule from the menu and does additional * pipes and checking that the elements are present on the @@ -128,6 +138,13 @@ export const deleteRuleFromDetailsPage = () => { cy.get(CONFIRM_DELETE_RULE_BTN).click(); }; +export const manualRuleRunFromDetailsPage = () => { + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_MANUAL_RULE_RUN_BTN).click(); + cy.get(RULE_DETAILS_MANUAL_RULE_RUN_BTN).should('not.exist'); + cy.get(MODAL_CONFIRMATION_BTN).click(); +}; + export const exportRule = (name: string) => { cy.log(`Export rule "${name}"`); diff --git a/x-pack/test/security_solution_cypress/serverless_config.ts b/x-pack/test/security_solution_cypress/serverless_config.ts index 04a15e49d070a..ebdd5d1b333c9 100644 --- a/x-pack/test/security_solution_cypress/serverless_config.ts +++ b/x-pack/test/security_solution_cypress/serverless_config.ts @@ -37,6 +37,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertSuppressionForEsqlRuleEnabled', 'bulkCustomHighlightedFieldsEnabled', + 'manualRuleRunEnabled', ])}`, ], },