diff --git a/src/platform/plugins/shared/workflows_management/moon.yml b/src/platform/plugins/shared/workflows_management/moon.yml index 2b3a0bd15c8e8..b4e16ae3b8378 100644 --- a/src/platform/plugins/shared/workflows_management/moon.yml +++ b/src/platform/plugins/shared/workflows_management/moon.yml @@ -96,6 +96,7 @@ dependsOn: - '@kbn/logging' - '@kbn/core-saved-objects-api-server' - '@kbn/management-settings-ids' + - '@kbn/rule-data-utils' tags: - plugin - prod diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/types.ts b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/types.ts index f82ed6940fdef..484e02b2e5744 100644 --- a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/types.ts +++ b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/types.ts @@ -11,10 +11,14 @@ export type WorkflowsConfig = Record; export type WorkflowsSecrets = Record; +export type AlertStateId = 'new' | 'ongoing' | 'recovered'; +export type AlertStates = Record; + export interface WorkflowsActionParams { subAction: string; subActionParams: { workflowId: string; summaryMode?: boolean; + alertStates?: AlertStates; }; } diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.test.tsx b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.test.tsx index 1f900d3b1c4ad..6755456e2e8d4 100644 --- a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.test.tsx @@ -120,7 +120,11 @@ describe('WorkflowsParamsFields', () => { expect(mockEditAction).toHaveBeenCalledWith('subAction', 'run', 0); expect(mockEditAction).toHaveBeenCalledWith( 'subActionParams', - { workflowId: '', summaryMode: true }, + { + workflowId: '', + summaryMode: true, + alertStates: { new: true, ongoing: false, recovered: false }, + }, 0 ); }); @@ -822,6 +826,212 @@ describe('WorkflowsParamsFields', () => { }); }); + describe('Alert state checkboxes (alertStates parameter)', () => { + test('should render alert state checkboxes', async () => { + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getAllByText('Run workflow for').length).toBeGreaterThan(0); + expect(screen.getByLabelText('New alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Ongoing alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Recovered alerts')).toBeInTheDocument(); + }); + }); + + test('should default to new=checked, ongoing=unchecked, recovered=unchecked', async () => { + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByLabelText('New alerts')).toBeChecked(); + expect(screen.getByLabelText('Ongoing alerts')).not.toBeChecked(); + expect(screen.getByLabelText('Recovered alerts')).not.toBeChecked(); + }); + }); + + test('should reflect alertStates from action params', async () => { + const props = { + ...defaultProps, + actionParams: { + subAction: 'run', + subActionParams: { + workflowId: 'test-workflow', + alertStates: { new: true, ongoing: true, recovered: false }, + }, + } as WorkflowsActionParams, + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByLabelText('New alerts')).toBeChecked(); + expect(screen.getByLabelText('Ongoing alerts')).toBeChecked(); + expect(screen.getByLabelText('Recovered alerts')).not.toBeChecked(); + }); + }); + + test('should toggle alert state on checkbox click', async () => { + const props = { + ...defaultProps, + actionParams: { + subAction: 'run', + subActionParams: { + workflowId: 'test-workflow', + summaryMode: true, + alertStates: { new: true, ongoing: false, recovered: false }, + }, + } as WorkflowsActionParams, + }; + + await act(async () => { + renderWithIntl(); + }); + + const recoveredCheckbox = screen.getByLabelText('Recovered alerts'); + await act(async () => { + fireEvent.click(recoveredCheckbox); + }); + + await waitFor(() => { + expect(mockEditAction).toHaveBeenCalledWith( + 'subActionParams', + expect.objectContaining({ + alertStates: { new: true, ongoing: false, recovered: true }, + }), + 0 + ); + }); + }); + + test('should initialize alertStates when missing from subActionParams', async () => { + const props = { + ...defaultProps, + actionParams: { + subAction: 'run', + subActionParams: { + workflowId: 'test-workflow', + summaryMode: true, + }, + } as WorkflowsActionParams, + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(mockEditAction).toHaveBeenCalledWith( + 'subActionParams', + expect.objectContaining({ + alertStates: { new: true, ongoing: false, recovered: false }, + }), + 0 + ); + }); + }); + + test('should initialize alertStates together with summaryMode in a single editAction call', async () => { + const props = { + ...defaultProps, + actionParams: { + subAction: 'run', + subActionParams: { + workflowId: 'test-workflow', + }, + } as any, + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(mockEditAction).toHaveBeenCalledWith( + 'subActionParams', + { + workflowId: 'test-workflow', + summaryMode: true, + alertStates: { new: true, ongoing: false, recovered: false }, + }, + 0 + ); + }); + }); + + test('should hide alert state checkboxes for Security detection rules', async () => { + const props = { + ...defaultProps, + ruleTypeId: 'siem.queryRule', + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.queryByLabelText('New alerts')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Ongoing alerts')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Recovered alerts')).not.toBeInTheDocument(); + }); + }); + + test('should hide alert state checkboxes for attack-discovery rules', async () => { + const props = { + ...defaultProps, + ruleTypeId: 'attack-discovery', + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.queryByLabelText('New alerts')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Ongoing alerts')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Recovered alerts')).not.toBeInTheDocument(); + }); + }); + + test('should show alert state checkboxes for non-SIEM rules', async () => { + const props = { + ...defaultProps, + ruleTypeId: 'metrics.alert.threshold', + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByLabelText('New alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Ongoing alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Recovered alerts')).toBeInTheDocument(); + }); + }); + + test('should show alert state checkboxes when ruleTypeId is undefined', async () => { + const props = { + ...defaultProps, + ruleTypeId: undefined, + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByLabelText('New alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Ongoing alerts')).toBeInTheDocument(); + expect(screen.getByLabelText('Recovered alerts')).toBeInTheDocument(); + }); + }); + }); + describe('Action frequency (summaryMode parameter)', () => { test('should render Action frequency section with switch', async () => { await act(async () => { @@ -854,7 +1064,7 @@ describe('WorkflowsParamsFields', () => { await waitFor(() => { expect(mockEditAction).toHaveBeenCalledWith( 'subActionParams', - { workflowId: 'test-workflow', summaryMode: true }, + expect.objectContaining({ workflowId: 'test-workflow', summaryMode: true }), 0 ); }); @@ -876,7 +1086,11 @@ describe('WorkflowsParamsFields', () => { await waitFor(() => { expect(mockEditAction).toHaveBeenCalledWith( 'subActionParams', - { workflowId: '', summaryMode: true }, + { + workflowId: '', + summaryMode: true, + alertStates: { new: true, ongoing: false, recovered: false }, + }, 0 ); }); diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.tsx b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.tsx index 11d96e52db60a..ed04245cb8d21 100644 --- a/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.tsx +++ b/src/platform/plugins/shared/workflows_management/public/connectors/workflows/workflows_params.tsx @@ -8,6 +8,7 @@ */ import { + EuiCheckboxGroup, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -15,11 +16,59 @@ import { EuiSpacer, EuiSwitch, } from '@elastic/eui'; +import type { EuiCheckboxGroupOption } from '@elastic/eui'; import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; +import { isSiemRuleType } from '@kbn/rule-data-utils'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { WorkflowSelectorWithProvider } from '@kbn/workflows-ui'; -import type { WorkflowsActionParams } from './types'; +import type { AlertStateId, AlertStates, WorkflowsActionParams } from './types'; + +const RUN_WORKFLOW_FOR_LABEL = i18n.translate( + 'xpack.stackConnectors.components.workflows.runWorkflowFor.label', + { + defaultMessage: 'Run workflow for', + } +); + +const RUN_WORKFLOW_FOR_HELP_TEXT = i18n.translate( + 'xpack.stackConnectors.components.workflows.runWorkflowFor.helpText', + { + defaultMessage: 'Select which alert states should trigger this workflow', + } +); + +const ALERT_STATE_OPTIONS: EuiCheckboxGroupOption[] = [ + { + id: 'new', + label: i18n.translate('xpack.stackConnectors.components.workflows.alertState.new', { + defaultMessage: 'New alerts', + }), + }, + { + id: 'ongoing', + label: i18n.translate('xpack.stackConnectors.components.workflows.alertState.ongoing', { + defaultMessage: 'Ongoing alerts', + }), + }, + { + id: 'recovered', + label: i18n.translate('xpack.stackConnectors.components.workflows.alertState.recovered', { + defaultMessage: 'Recovered alerts', + }), + }, +]; + +const DEFAULT_ALERT_STATES: AlertStates = { + new: true, + ongoing: false, + recovered: false, +}; + +const normalizeAlertStates = (partial?: Partial): AlertStates => ({ + ...DEFAULT_ALERT_STATES, + ...partial, +}); const RUN_PER_ALERT_LABEL = i18n.translate( 'xpack.stackConnectors.components.workflows.runPerAlert.label', @@ -40,8 +89,13 @@ const WorkflowsParamsFields: React.FunctionComponent { + // Security detection rules only produce "new" alerts and have no concept of + // "ongoing" or "recovered" states, so the alert-state checkboxes are hidden. + const isDetectionRule = ruleTypeId ? isSiemRuleType(ruleTypeId) : false; const { workflowId, summaryMode = true } = actionParams.subActionParams ?? {}; + const alertStates = normalizeAlertStates(actionParams.subActionParams?.alertStates); const handleWorkflowChange = useCallback( (newWorkflowId: string) => { @@ -56,8 +110,6 @@ const WorkflowsParamsFields: React.FunctionComponent { - // When switch is ON (runPerAlert = true), summaryMode should be false (run per alert) - // When switch is OFF (runPerAlert = false), summaryMode should be true (summary mode) editAction( 'subActionParams', { ...actionParams.subActionParams, summaryMode: !runPerAlert }, @@ -67,16 +119,58 @@ const WorkflowsParamsFields: React.FunctionComponent { + const stateId = optionId as AlertStateId; + const currentStates = normalizeAlertStates(actionParams.subActionParams?.alertStates); + editAction( + 'subActionParams', + { + ...actionParams.subActionParams, + alertStates: { + ...currentStates, + [stateId]: !currentStates[stateId], + }, + }, + index + ); + }, + [editAction, index, actionParams.subActionParams] + ); + useEffect(() => { if (!actionParams?.subAction) { editAction('subAction', 'run', index); } if (!actionParams?.subActionParams) { - editAction('subActionParams', { workflowId: '', summaryMode: true }, index); - } else if (actionParams.subActionParams.summaryMode === undefined) { - // Ensure summaryMode defaults to true for backward compatibility - editAction('subActionParams', { ...actionParams.subActionParams, summaryMode: true }, index); + editAction( + 'subActionParams', + { workflowId: '', summaryMode: true, alertStates: DEFAULT_ALERT_STATES }, + index + ); + } else { + let nextSubActionParams = actionParams.subActionParams; + let needsUpdate = false; + + if (nextSubActionParams.summaryMode === undefined) { + nextSubActionParams = { ...nextSubActionParams, summaryMode: true }; + needsUpdate = true; + } + + const mergedAlertStates = normalizeAlertStates(nextSubActionParams.alertStates); + if ( + !nextSubActionParams.alertStates || + nextSubActionParams.alertStates.new !== mergedAlertStates.new || + nextSubActionParams.alertStates.ongoing !== mergedAlertStates.ongoing || + nextSubActionParams.alertStates.recovered !== mergedAlertStates.recovered + ) { + nextSubActionParams = { ...nextSubActionParams, alertStates: mergedAlertStates }; + needsUpdate = true; + } + + if (needsUpdate) { + editAction('subActionParams', nextSubActionParams, index); + } } }, [actionParams, editAction, index]); @@ -108,6 +202,30 @@ const WorkflowsParamsFields: React.FunctionComponent + {!isDetectionRule && ( + <> + + + {RUN_WORKFLOW_FOR_LABEL} + + + + + } + > + + + + )}