diff --git a/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.test.ts b/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.test.ts new file mode 100644 index 0000000000000..1fb2396e86f02 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.test.ts @@ -0,0 +1,94 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CombinedSummarizedAlerts } from '@kbn/alerting-plugin/server/types'; +import { buildAlertEvent } from './build_alert_event'; +import type { AlertEventRule } from '../types/alert_types'; + +const mockRule: AlertEventRule = { + id: 'rule-1', + name: 'Test Rule', + tags: ['test'], + consumer: 'alerts', + producer: 'infrastructure', + ruleTypeId: 'metrics.alert.threshold', +}; + +const makeAlertGroup = (ids: string[]) => ({ + count: ids.length, + data: ids.map((id) => ({ _id: id, _index: 'test-index' })), + alert_count: { active: ids.length, recovered: 0, ignored: 0 }, +}); + +describe('buildAlertEvent', () => { + it('should include only new alerts when ongoing and recovered are empty', () => { + const alerts = { + new: makeAlertGroup(['alert-new-1', 'alert-new-2']), + ongoing: makeAlertGroup([]), + recovered: makeAlertGroup([]), + all: makeAlertGroup(['alert-new-1', 'alert-new-2']), + } as unknown as CombinedSummarizedAlerts; + + const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' }); + + expect(result.alerts).toHaveLength(2); + expect(result.alerts.map((a) => a._id)).toEqual(['alert-new-1', 'alert-new-2']); + }); + + it('should merge alerts from all three states', () => { + const alerts = { + new: makeAlertGroup(['alert-new-1']), + ongoing: makeAlertGroup(['alert-ongoing-1']), + recovered: makeAlertGroup(['alert-recovered-1']), + all: makeAlertGroup(['alert-new-1', 'alert-ongoing-1', 'alert-recovered-1']), + } as unknown as CombinedSummarizedAlerts; + + const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' }); + + expect(result.alerts).toHaveLength(3); + expect(result.alerts.map((a) => a._id)).toEqual([ + 'alert-new-1', + 'alert-ongoing-1', + 'alert-recovered-1', + ]); + }); + + it('should handle undefined state data gracefully', () => { + const alerts = { + new: { count: 0, data: undefined }, + ongoing: { count: 0, data: undefined }, + recovered: { count: 0, data: undefined }, + all: makeAlertGroup([]), + } as unknown as CombinedSummarizedAlerts; + + const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' }); + + expect(result.alerts).toEqual([]); + }); + + it('should populate rule information correctly', () => { + const alerts = { + new: makeAlertGroup(['alert-1']), + ongoing: makeAlertGroup([]), + recovered: makeAlertGroup([]), + all: makeAlertGroup(['alert-1']), + } as unknown as CombinedSummarizedAlerts; + + const result = buildAlertEvent({ + alerts, + rule: mockRule, + ruleUrl: 'https://kibana/rule/1', + spaceId: 'my-space', + }); + + expect(result.rule).toEqual(mockRule); + expect(result.ruleUrl).toBe('https://kibana/rule/1'); + expect(result.spaceId).toBe('my-space'); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.ts b/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.ts index a75aabd875294..b34bfe2a4a191 100644 --- a/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.ts +++ b/src/platform/plugins/shared/workflows_management/common/utils/build_alert_event.ts @@ -22,7 +22,11 @@ export function buildAlertEvent(params: { spaceId: string; }): AlertEvent { return { - alerts: params.alerts.new.data, + alerts: [ + ...(params.alerts.new?.data || []), + ...(params.alerts.ongoing?.data || []), + ...(params.alerts.recovered?.data || []), + ], rule: { id: params.rule.id, name: params.rule.name, 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..e226011ffcb9d 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,18 @@ export type WorkflowsConfig = Record; export type WorkflowsSecrets = Record; +export interface AlertStates { + [key: string]: boolean | undefined; + new?: boolean; + ongoing?: boolean; + recovered?: boolean; +} + 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 7797d91ca38e5..f31a2c9303259 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 @@ -123,7 +123,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 ); }); @@ -919,7 +923,11 @@ describe('WorkflowsParamsFields', () => { await waitFor(() => { expect(mockEditAction).toHaveBeenCalledWith( 'subActionParams', - { workflowId: '', summaryMode: true }, + { + workflowId: '', + summaryMode: true, + alertStates: { new: true, ongoing: false, recovered: false }, + }, 0 ); }); @@ -1094,4 +1102,135 @@ describe('WorkflowsParamsFields', () => { ); }); }); + + describe('Alert states (alertStates parameter)', () => { + test('should render "Run workflow for" section with checkboxes', async () => { + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByText('Run workflow for')).toBeInTheDocument(); + expect(screen.getByText('Firing alerts')).toBeInTheDocument(); + expect(screen.getByText('Ongoing alerts')).toBeInTheDocument(); + expect(screen.getByText('Recovered alerts')).toBeInTheDocument(); + }); + }); + + test('should render checkboxes with correct defaults (only firing checked)', 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(); + }); + + await waitFor(() => { + const checkboxes = screen.getByTestId('workflow-alert-states-checkboxes'); + expect(checkboxes).toBeInTheDocument(); + + const firingCheckbox = screen.getByLabelText('Firing alerts'); + const ongoingCheckbox = screen.getByLabelText('Ongoing alerts'); + const recoveredCheckbox = screen.getByLabelText('Recovered alerts'); + + expect(firingCheckbox).toBeChecked(); + expect(ongoingCheckbox).not.toBeChecked(); + expect(recoveredCheckbox).not.toBeChecked(); + }); + }); + + test('should toggle recovered checkbox and call editAction', 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 show all checkboxes as checked when all states are enabled', async () => { + const props = { + ...defaultProps, + actionParams: { + subAction: 'run', + subActionParams: { + workflowId: 'test-workflow', + summaryMode: true, + alertStates: { new: true, ongoing: true, recovered: true }, + }, + } as WorkflowsActionParams, + }; + + await act(async () => { + renderWithIntl(); + }); + + await waitFor(() => { + expect(screen.getByLabelText('Firing alerts')).toBeChecked(); + expect(screen.getByLabelText('Ongoing alerts')).toBeChecked(); + expect(screen.getByLabelText('Recovered alerts')).toBeChecked(); + }); + }); + }); }); 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..2874cc3797363 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,8 @@ */ import { + EuiCheckboxGroup, + type EuiCheckboxGroupOption, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -19,7 +21,7 @@ import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { WorkflowSelectorWithProvider } from '@kbn/workflows-ui'; -import type { WorkflowsActionParams } from './types'; +import type { AlertStates, WorkflowsActionParams } from './types'; const RUN_PER_ALERT_LABEL = i18n.translate( 'xpack.stackConnectors.components.workflows.runPerAlert.label', @@ -41,7 +43,11 @@ const WorkflowsParamsFields: React.FunctionComponent { - const { workflowId, summaryMode = true } = actionParams.subActionParams ?? {}; + const { + workflowId, + summaryMode = true, + alertStates = { new: true, ongoing: false, recovered: false }, + } = actionParams.subActionParams ?? {}; const handleWorkflowChange = useCallback( (newWorkflowId: string) => { @@ -67,16 +73,54 @@ const WorkflowsParamsFields: React.FunctionComponent { + const newAlertStates: AlertStates = { + ...alertStates, + [optionId]: !alertStates[optionId as keyof AlertStates], + }; + editAction( + 'subActionParams', + { ...actionParams.subActionParams, alertStates: newAlertStates }, + index + ); + }, + [editAction, index, actionParams.subActionParams, alertStates] + ); + // Ensure proper initialization of action parameters 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: { new: true, ongoing: false, recovered: false }, + }, + index + ); + } else { + if (actionParams.subActionParams.summaryMode === undefined) { + editAction( + 'subActionParams', + { ...actionParams.subActionParams, summaryMode: true }, + index + ); + } + if (!actionParams.subActionParams.alertStates) { + editAction( + 'subActionParams', + { + ...actionParams.subActionParams, + alertStates: { new: true, ongoing: false, recovered: false }, + }, + index + ); + } } }, [actionParams, editAction, index]); @@ -88,6 +132,27 @@ const WorkflowsParamsFields: React.FunctionComponent + + + {i18n.translate('xpack.stackConnectors.components.workflows.alertStates.label', { + defaultMessage: 'Run workflow for', + })} + + + + + + } + > + + + { expect(result.subActionParams.inputs?.event?.alerts).toHaveLength(1); }); + it('should default to only new alerts when alertStates is not provided', () => { + const adapter = getWorkflowsConnectorAdapter(); + + const mockAlerts = { + all: { data: [], count: 0 }, + new: { data: [{ _id: 'alert-new-1', _index: 'idx' }], count: 1 }, + ongoing: { data: [{ _id: 'alert-ongoing-1', _index: 'idx' }], count: 1 }, + recovered: { data: [{ _id: 'alert-recovered-1', _index: 'idx' }], count: 1 }, + }; + + const mockRule = { + id: 'rule-id', + name: 'test rule', + tags: [], + consumer: 'test-consumer', + producer: 'test-producer', + ruleTypeId: 'test-rule-type', + }; + + const result = adapter.buildActionParams({ + alerts: mockAlerts as any, + rule: mockRule, + params: { subAction: 'run' as const, subActionParams: { workflowId: 'wf-1' } }, + ruleUrl: 'https://example.com/rule', + spaceId: 'default', + }); + + const alertIds = result.subActionParams.inputs?.event?.alerts?.map((a: any) => a._id); + expect(alertIds).toEqual(['alert-new-1']); + expect(result.subActionParams.alertStates).toEqual({ + new: true, + ongoing: false, + recovered: false, + }); + }); + + it('should include recovered alerts when alertStates.recovered is true', () => { + const adapter = getWorkflowsConnectorAdapter(); + + const mockAlerts = { + all: { data: [], count: 0 }, + new: { data: [{ _id: 'alert-new-1', _index: 'idx' }], count: 1 }, + ongoing: { data: [], count: 0 }, + recovered: { data: [{ _id: 'alert-recovered-1', _index: 'idx' }], count: 1 }, + }; + + const mockRule = { + id: 'rule-id', + name: 'test rule', + tags: [], + consumer: 'test-consumer', + producer: 'test-producer', + ruleTypeId: 'test-rule-type', + }; + + const result = adapter.buildActionParams({ + alerts: mockAlerts as any, + rule: mockRule, + params: { + subAction: 'run' as const, + subActionParams: { + workflowId: 'wf-1', + alertStates: { new: true, ongoing: false, recovered: true }, + }, + }, + ruleUrl: 'https://example.com/rule', + spaceId: 'default', + }); + + const alertIds = result.subActionParams.inputs?.event?.alerts?.map((a: any) => a._id); + expect(alertIds).toEqual(['alert-new-1', 'alert-recovered-1']); + }); + + it('should include only recovered alerts when only recovered is selected', () => { + const adapter = getWorkflowsConnectorAdapter(); + + const mockAlerts = { + all: { data: [], count: 0 }, + new: { data: [{ _id: 'alert-new-1', _index: 'idx' }], count: 1 }, + ongoing: { data: [{ _id: 'alert-ongoing-1', _index: 'idx' }], count: 1 }, + recovered: { data: [{ _id: 'alert-recovered-1', _index: 'idx' }], count: 1 }, + }; + + const mockRule = { + id: 'rule-id', + name: 'test rule', + tags: [], + consumer: 'test-consumer', + producer: 'test-producer', + ruleTypeId: 'test-rule-type', + }; + + const result = adapter.buildActionParams({ + alerts: mockAlerts as any, + rule: mockRule, + params: { + subAction: 'run' as const, + subActionParams: { + workflowId: 'wf-1', + alertStates: { new: false, ongoing: false, recovered: true }, + }, + }, + ruleUrl: 'https://example.com/rule', + spaceId: 'default', + }); + + const alertIds = result.subActionParams.inputs?.event?.alerts?.map((a: any) => a._id); + expect(alertIds).toEqual(['alert-recovered-1']); + }); + + it('should include all alert states when all are enabled', () => { + const adapter = getWorkflowsConnectorAdapter(); + + const mockAlerts = { + all: { data: [], count: 0 }, + new: { data: [{ _id: 'new-1', _index: 'idx' }], count: 1 }, + ongoing: { data: [{ _id: 'ongoing-1', _index: 'idx' }], count: 1 }, + recovered: { data: [{ _id: 'recovered-1', _index: 'idx' }], count: 1 }, + }; + + const mockRule = { + id: 'rule-id', + name: 'test rule', + tags: [], + consumer: 'test-consumer', + producer: 'test-producer', + ruleTypeId: 'test-rule-type', + }; + + const result = adapter.buildActionParams({ + alerts: mockAlerts as any, + rule: mockRule, + params: { + subAction: 'run' as const, + subActionParams: { + workflowId: 'wf-1', + alertStates: { new: true, ongoing: true, recovered: true }, + }, + }, + ruleUrl: 'https://example.com/rule', + spaceId: 'default', + }); + + const alertIds = result.subActionParams.inputs?.event?.alerts?.map((a: any) => a._id); + expect(alertIds).toEqual(['new-1', 'ongoing-1', 'recovered-1']); + }); + it('should handle missing workflowId gracefully', () => { const adapter = getWorkflowsConnectorAdapter(); diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/index.ts b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/index.ts index d55d9aaaaf614..2916b80a9ccc7 100644 --- a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/index.ts +++ b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/index.ts @@ -25,6 +25,7 @@ import { } from './service'; import * as i18n from './translations'; import type { + AlertStates, ExecutorParams, ExecutorSubActionRunParams, WorkflowsActionParamsType, @@ -41,6 +42,7 @@ export interface WorkflowsRuleActionParams { subActionParams: { workflowId: string; summaryMode?: boolean; + alertStates?: AlertStates; }; [key: string]: unknown; } @@ -171,15 +173,31 @@ export function getWorkflowsConnectorAdapter(): ConnectorAdapter< } const { workflowId, summaryMode = true } = subActionParams; + const resolvedAlertStates = { + new: subActionParams.alertStates?.new !== false, + ongoing: subActionParams.alertStates?.ongoing === true, + recovered: subActionParams.alertStates?.recovered === true, + }; if (!workflowId) { throw new Error( `Missing required workflowId parameter. Received params: ${JSON.stringify(params)}` ); } - // Build alert event using shared utility function + const emptyAlertGroup = { + count: 0, + data: [], + alert_count: { active: 0, recovered: 0, ignored: 0 }, + }; + const filteredAlerts = { + ...alerts, + new: resolvedAlertStates.new ? alerts.new : emptyAlertGroup, + ongoing: resolvedAlertStates.ongoing ? alerts.ongoing : emptyAlertGroup, + recovered: resolvedAlertStates.recovered ? alerts.recovered : emptyAlertGroup, + }; + const alertEvent = buildAlertEvent({ - alerts, + alerts: filteredAlerts, rule, ruleUrl, spaceId, @@ -192,15 +210,22 @@ export function getWorkflowsConnectorAdapter(): ConnectorAdapter< inputs: { event: alertEvent }, spaceId, summaryMode, + alertStates: resolvedAlertStates, }, }; } catch (error) { + const fallbackStates = params?.subActionParams?.alertStates; return { subAction: 'run' as const, subActionParams: { workflowId: params?.subActionParams?.workflowId || 'unknown', spaceId, summaryMode: params?.subActionParams?.summaryMode ?? true, + alertStates: { + new: fallbackStates?.new !== false, + ongoing: fallbackStates?.ongoing === true, + recovered: fallbackStates?.recovered === true, + }, }, }; } diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/schema.ts b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/schema.ts index dbbfb68e0c74e..0af5b32c9cbfe 100644 --- a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/schema.ts +++ b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/schema.ts @@ -10,11 +10,18 @@ import { schema } from '@kbn/config-schema'; import { z } from '@kbn/zod'; +const AlertStatesSchema = z.object({ + new: z.boolean().optional().default(true), + ongoing: z.boolean().optional().default(false), + recovered: z.boolean().optional().default(false), +}); + const RunSubActionParamsSchema = z.object({ workflowId: z.string(), inputs: z.any().optional(), spaceId: z.string(), summaryMode: z.boolean().optional().default(true), + alertStates: AlertStatesSchema.optional(), }); // Schema for rule configuration (what the UI saves) @@ -24,6 +31,13 @@ export const WorkflowsRuleActionParamsSchema = schema.object({ workflowId: schema.string(), inputs: schema.maybe(schema.any()), summaryMode: schema.maybe(schema.boolean()), + alertStates: schema.maybe( + schema.object({ + new: schema.maybe(schema.boolean()), + ongoing: schema.maybe(schema.boolean()), + recovered: schema.maybe(schema.boolean()), + }) + ), }), }); diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/types.ts b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/types.ts index d3bc4c88f8bf8..606fc75a91331 100644 --- a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/types.ts +++ b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/types.ts @@ -16,10 +16,17 @@ import type { ExecutorParamsSchema } from './schema'; export type ExecutorParams = z.infer; export type WorkflowsActionParamsType = ExecutorParams; +export interface AlertStates { + new?: boolean; + ongoing?: boolean; + recovered?: boolean; +} + export interface RunWorkflowParams { workflowId: string; spaceId: string; summaryMode?: boolean; + alertStates?: AlertStates; inputs: { event: { alerts: AlertHit[]; diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index 7d45878d9ebac..eea2dcc47c7d8 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -4929,6 +4929,24 @@ Object { "subActionParams": Object { "additionalProperties": false, "properties": Object { + "alertStates": Object { + "additionalProperties": false, + "properties": Object { + "new": Object { + "default": true, + "type": "boolean", + }, + "ongoing": Object { + "default": false, + "type": "boolean", + }, + "recovered": Object { + "default": false, + "type": "boolean", + }, + }, + "type": "object", + }, "inputs": Object {}, "spaceId": Object { "type": "string",