From cfbe35b9a486c8fa6faa31dd86cc0690d58764e0 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 23 Mar 2026 11:32:19 +0100 Subject: [PATCH 01/28] Add actions' visualization to episode table: - snooze/unsnooze - activate/deactivate - acknowledge/unacknowledge --- .../components/acknowledge_action_button.tsx | 39 ++++++++ .../components/alerting_episode_tags.tsx | 41 ++++++++ .../components/deactivate_action_button.tsx | 42 +++++++++ .../components/snooze_action_button.tsx | 39 ++++++++ .../hooks/use_fetch_episode_actions.ts | 93 +++++++++++++++++++ .../types/episode_action.ts | 16 ++++ .../server/resources/alert_actions.ts | 1 + .../public/pages/alerts_v2/alerts_v2.tsx | 73 +++++++++++++++ 8 files changed, 344 insertions(+) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx new file mode 100644 index 0000000000000..1a5f8ace734bf --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface AcknowledgeActionButtonProps { + lastAckAction: string | null; +} + +export function AcknowledgeActionButton({ lastAckAction }: AcknowledgeActionButtonProps) { + const isAcknowledged = lastAckAction === 'ack'; + + const label = isAcknowledged + ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.acknowledged', { + defaultMessage: 'Acknowledged', + }) + : i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledged', { + defaultMessage: 'Unacknowledged', + }); + + return ( + {}} + onClickAriaLabel={label} + data-test-subj="alertingEpisodeAcknowledgeActionButton" + > + + {label} + + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx new file mode 100644 index 0000000000000..198cd8fcb4ba8 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +export function AlertingEpisodeTags({ + tags, + color, + size = 3, + oneLine = false, +}: { + tags?: string[] | null; + color?: string; + size?: number; + oneLine?: boolean; +}) { + if (!tags?.length) { + return -; + } + + return ( + + {tags.slice(0, size).map((tag, index) => ( + + {tag} + + ))} + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx new file mode 100644 index 0000000000000..49b61cbf8759b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface DeactivateActionButtonProps { + lastDeactivateAction: string | null; +} + +export function DeactivateActionButton({ lastDeactivateAction }: DeactivateActionButtonProps) { + const isDeactivated = lastDeactivateAction === 'deactivate'; + + const label = isDeactivated + ? i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.deactivated', { + defaultMessage: 'Deactivated', + }) + : i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.active', { + defaultMessage: 'Active', + }); + + return ( + {}} + onClickAriaLabel={label} + data-test-subj="alertingEpisodeDeactivateActionButton" + > + + {label} + + + + + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx new file mode 100644 index 0000000000000..1c7488f8a558e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface SnoozeActionButtonProps { + lastSnoozeAction: string | null; +} + +export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps) { + const isSnoozed = lastSnoozeAction === 'snooze'; + + return isSnoozed ? ( + {}} + data-test-subj="alertingEpisodeSnoozeActionButton" + /> + ) : ( + {}} + data-test-subj="alertingEpisodeSnoozeActionButton" + /> + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts new file mode 100644 index 0000000000000..76b18ff262870 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -0,0 +1,93 @@ +/* + * 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 { useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { EpisodeAction } from '../types/episode_action'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; + +const ALERT_ACTIONS_DATA_STREAM = '.alerting-actions'; + +const escapeEsqlString = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +const tagsFromRow = (value: unknown): string[] | null => { + if (value == null) { + return null; + } + if (typeof value === 'string') { + return [value]; + } + if (Array.isArray(value)) { + return value as string[]; + } + return null; +}; + +const buildBulkGetAlertActionsQuery = (episodeIds: string[]): string => { + const escapedIds = episodeIds.map((id) => `"${escapeEsqlString(id)}"`).join(', '); + + return ` + FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE episode_id IN (${escapedIds}) + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze", "tag") + | STATS + tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), + last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), + last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") + BY episode_id, rule_id, group_hash + | KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action, tags + `; +}; + +export interface UseFetchEpisodeActionsOptions { + episodeIds: string[]; + services: { expressions: ExpressionsStart }; +} + +export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisodeActionsOptions) => { + const { data, isLoading } = useQuery({ + queryKey: ['fetchEpisodeActions', episodeIds], + queryFn: async ({ signal }) => { + const query = buildBulkGetAlertActionsQuery(episodeIds); + const result = await executeEsqlQuery({ + expressions: services.expressions, + query, + input: null, + abortSignal: signal, + }); + + return result.rows.map( + (row): EpisodeAction => ({ + episode_id: row.episode_id as string, + rule_id: (row.rule_id as string) ?? null, + group_hash: (row.group_hash as string) ?? null, + last_ack_action: (row.last_ack_action as string) ?? null, + last_deactivate_action: (row.last_deactivate_action as string) ?? null, + last_snooze_action: (row.last_snooze_action as string) ?? null, + tags: tagsFromRow(row.tags), + }) + ); + }, + enabled: episodeIds.length > 0, + keepPreviousData: true, + }); + + const actionsMap = useMemo(() => { + const map = new Map(); + if (data) { + for (const action of data) { + map.set(action.episode_id, action); + } + } + return map; + }, [data]); + + return { data: data ?? [], actionsMap, isLoading }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts new file mode 100644 index 0000000000000..66dab324bbfa8 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EpisodeAction { + episode_id: string; + rule_id: string | null; + group_hash: string | null; + last_ack_action: string | null; + last_deactivate_action: string | null; + last_snooze_action: string | null; + tags: string[] | null; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts index 79a2d7801b765..dd564212c5b15 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts @@ -44,6 +44,7 @@ const mappings: MappingsDefinition = { notification_group_id: { type: 'keyword' }, source: { type: 'keyword' }, reason: { type: 'text' }, + tags: { type: 'keyword' }, }, }; diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index 9031b9dc5a08f..b26fbab005b76 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -30,7 +30,12 @@ import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resource import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query'; import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; import { AlertingEpisodeStatusBadge } from '@kbn/alerting-v2-episodes-ui/components/alerting_episode_status_badge'; +import { AlertingEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alerting_episode_tags'; +import { AcknowledgeActionButton } from '@kbn/alerting-v2-episodes-ui/components/acknowledge_action_button'; +import { SnoozeActionButton } from '@kbn/alerting-v2-episodes-ui/components/snooze_action_button'; +import { DeactivateActionButton } from '@kbn/alerting-v2-episodes-ui/components/deactivate_action_button'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; +import { useFetchEpisodeActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions'; import { useKibana } from '../../utils/kibana_react'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { HeaderMenu } from '../overview/components/header_menu/header_menu'; @@ -42,6 +47,8 @@ const ALERTS_V2_TABLE_SETTINGS: UnifiedDataTableSettings = { columns: { duration: { width: 100 }, 'episode.status': { width: 128 }, + state: { width: 280 }, + notify: { width: 80 }, }, }; @@ -59,7 +66,10 @@ export function AlertsV2Page() { '@timestamp', 'rule.id', 'episode.status', + 'notify', 'duration', + 'tags', + 'state', ]); const [rowHeight, setRowHeight] = useState(2); @@ -90,6 +100,13 @@ export function AlertsV2Page() { const rows = useMemo(() => pagesToDatatableRecords(episodesData?.pages), [episodesData?.pages]); + const episodeIds = useMemo( + () => rows.map((row) => row.flattened['episode.id'] as string).filter(Boolean), + [rows] + ); + + const { actionsMap } = useFetchEpisodeActions({ episodeIds, services }); + const onSetColumns = useCallback((cols: string[], _hideTimeCol: boolean) => { setColumns(cols); }, []); @@ -165,11 +182,67 @@ export function AlertsV2Page() { onSetColumns={onSetColumns} canDragAndDropColumns showTimeCol={!!dataView.timeFieldName} + customGridColumnsConfiguration={{ + state: ({ column }) => ({ + ...column, + displayAsText: i18n.translate( + 'xpack.observability.alertsV2.columns.currentState', + { + defaultMessage: 'Current state', + } + ), + }), + notify: ({ column }) => ({ + ...column, + displayAsText: i18n.translate('xpack.observability.alertsV2.columns.notify', { + defaultMessage: 'Notify', + }), + }), + tags: ({ column }) => ({ + ...column, + displayAsText: i18n.translate('xpack.observability.alertsV2.columns.tags', { + defaultMessage: 'Tags', + }), + }), + }} externalCustomRenderers={{ 'episode.status': (props) => { const status = props.row.flattened[props.columnId] as AlertEpisodeStatus; return ; }, + state: (props) => { + const episodeId = props.row.flattened['episode.id'] as string; + const episodeAction = actionsMap.get(episodeId); + return ( + + + + + + + + + ); + }, + notify: (props) => { + const episodeId = props.row.flattened['episode.id'] as string; + const episodeAction = actionsMap.get(episodeId); + return ( + + ); + }, + tags: (props) => { + const episodeId = props.row.flattened['episode.id'] as string; + const episodeAction = actionsMap.get(episodeId); + + return ; + }, 'rule.id': (props) => { if (!Object.keys(rulesIndex).length && isLoadingRules) { return ; From 2088e18bf277c9166a0b49179a12cea8894a7e9b Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 25 Mar 2026 11:08:56 +0100 Subject: [PATCH 02/28] Remove bulk get alert actions. --- .../src/alert_action_schema.ts | 37 ----- .../alert_actions_client.test.ts | 136 ------------------ .../alert_actions_client.ts | 26 ---- .../lib/alert_actions_client/queries.ts | 26 ---- .../server/resources/alert_actions.ts | 1 - .../bulk_get_alert_actions_route.ts | 57 -------- .../alerting_v2/server/setup/bind_routes.ts | 2 - 7 files changed, 285 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/queries.ts delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/bulk_get_alert_actions_route.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts index fbf6d42b88292..892a793d94a9a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts @@ -81,40 +81,3 @@ export const bulkCreateAlertActionBodySchema = z 'Request body for bulk create alert actions. Array of 1 to 100 actions, each with group_hash and action payload.' ); export type BulkCreateAlertActionBody = z.infer; - -export const bulkGetAlertActionsBodySchema = z - .object({ - episode_ids: z - .array(z.string()) - .min(1, 'At least one episode ID must be provided') - .max(100, 'Cannot query more than 100 episode IDs in a single request') - .describe('List of episode identifiers to fetch alert actions for.'), - }) - .describe('Request body for bulk getting alert actions by episode IDs.'); - -export type BulkGetAlertActionsBody = z.infer; - -export const bulkGetAlertActionsResponseSchema = z - .array( - z.object({ - episode_id: z.string().describe('The episode identifier.'), - rule_id: z.string().nullable().describe('The rule identifier, or null if not found.'), - group_hash: z.string().nullable().describe('The alert group hash, or null if not found.'), - last_ack_action: z - .enum(['ack', 'unack']) - .nullable() - .describe('The last acknowledge action, or null if none.'), - last_deactivate_action: z - .enum(['activate', 'deactivate']) - .nullable() - .describe('The last deactivate action, or null if none.'), - last_snooze_action: z - .enum(['snooze', 'unsnooze']) - .nullable() - .describe('The last snooze action, or null if none.'), - tags: z.array(z.string()).nullable().describe('The tags for the alert, or null if none.'), - }) - ) - .describe('Response body for bulk getting alert actions by episode IDs.'); - -export type BulkGetAlertActionsResponse = z.infer; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts index b95df557af955..bc4dc4f372668 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts @@ -14,7 +14,6 @@ import { createUserProfile, createUserService } from '../services/user_service/u import { AlertActionsClient } from './alert_actions_client'; import { getBulkAlertEventsESQLResponse, - getBulkGetAlertActionsESQLResponse, getAlertEventESQLResponse, getEmptyESQLResponse, } from './fixtures/query_responses'; @@ -215,139 +214,4 @@ describe('AlertActionsClient', () => { expect(storageServiceEsClient.bulk).not.toHaveBeenCalled(); }); }); - - describe('bulkGet', () => { - it('should return action states for multiple episode IDs', async () => { - queryServiceEsClient.esql.query.mockResolvedValueOnce( - getBulkGetAlertActionsESQLResponse([ - { - episode_id: 'episode-1', - rule_id: 'rule-1', - group_hash: 'hash-1', - last_ack_action: 'ack', - last_snooze_action: 'snooze', - }, - { - episode_id: 'episode-2', - rule_id: 'rule-2', - group_hash: 'hash-2', - last_deactivate_action: 'deactivate', - }, - ]) - ); - - const result = await client.bulkGet(['episode-1', 'episode-2']); - - expect(result).toEqual([ - { - episode_id: 'episode-1', - rule_id: 'rule-1', - group_hash: 'hash-1', - last_ack_action: 'ack', - last_deactivate_action: null, - last_snooze_action: 'snooze', - tags: null, - }, - { - episode_id: 'episode-2', - rule_id: 'rule-2', - group_hash: 'hash-2', - last_ack_action: null, - last_deactivate_action: 'deactivate', - last_snooze_action: null, - tags: null, - }, - ]); - }); - - it('should return default records with nulls for episodes without actions', async () => { - queryServiceEsClient.esql.query.mockResolvedValueOnce(getEmptyESQLResponse()); - - const result = await client.bulkGet(['unknown-episode']); - - expect(result).toEqual([ - { - episode_id: 'unknown-episode', - rule_id: null, - group_hash: null, - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - tags: null, - }, - ]); - }); - - it('should include both matched and unmatched episodes', async () => { - queryServiceEsClient.esql.query.mockResolvedValueOnce( - getBulkGetAlertActionsESQLResponse([{ episode_id: 'episode-1', last_ack_action: 'ack' }]) - ); - - const result = await client.bulkGet(['episode-1', 'episode-2']); - - expect(result).toEqual([ - { - episode_id: 'episode-1', - rule_id: 'test-rule-id', - group_hash: 'test-group-hash', - last_ack_action: 'ack', - last_deactivate_action: null, - last_snooze_action: null, - tags: null, - }, - { - episode_id: 'episode-2', - rule_id: null, - group_hash: null, - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - tags: null, - }, - ]); - }); - - it('should return tags for episodes with tag actions', async () => { - queryServiceEsClient.esql.query.mockResolvedValueOnce( - getBulkGetAlertActionsESQLResponse([ - { - episode_id: 'episode-1', - rule_id: 'rule-1', - group_hash: 'hash-1', - last_ack_action: 'ack', - tags: ['critical', 'network'], - }, - { - episode_id: 'episode-2', - rule_id: 'rule-2', - group_hash: 'hash-2', - tags: ['security'], - }, - ]) - ); - - const result = await client.bulkGet(['episode-1', 'episode-2']); - - expect(result).toEqual([ - { - episode_id: 'episode-1', - rule_id: 'rule-1', - group_hash: 'hash-1', - last_ack_action: 'ack', - last_deactivate_action: null, - last_snooze_action: null, - tags: ['critical', 'network'], - }, - { - episode_id: 'episode-2', - rule_id: 'rule-2', - group_hash: 'hash-2', - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - tags: ['security'], - }, - ]); - }); - }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts index 5fb5b378e8c36..4fa1f262fe39d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts @@ -11,7 +11,6 @@ import { inject, injectable } from 'inversify'; import { groupBy, omit } from 'lodash'; import type { BulkCreateAlertActionItemBody, - BulkGetAlertActionsResponse, CreateAlertActionBody, } from '@kbn/alerting-v2-schemas'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; @@ -23,7 +22,6 @@ import type { StorageServiceContract } from '../services/storage_service/storage import { StorageServiceScopedToken } from '../services/storage_service/tokens'; import type { UserServiceContract } from '../services/user_service/user_service'; import { UserService } from '../services/user_service/user_service'; -import { getBulkGetAlertActionsQuery } from './queries'; @injectable() export class AlertActionsClient { @@ -57,30 +55,6 @@ export class AlertActionsClient { }); } - public async bulkGet(episodeIds: string[]): Promise { - const query = getBulkGetAlertActionsQuery(episodeIds); - const records = queryResponseToRecords( - await this.queryService.executeQuery({ query: query.query }) - ); - - const returnedEpisodeIds = new Set(records.map((r) => r.episode_id)); - for (const episodeId of episodeIds) { - if (!returnedEpisodeIds.has(episodeId)) { - records.push({ - episode_id: episodeId, - rule_id: null, - group_hash: null, - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - tags: null, - }); - } - } - - return records; - } - public async createBulkActions( actions: BulkCreateAlertActionItemBody[] ): Promise<{ processed: number; total: number }> { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/queries.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/queries.ts deleted file mode 100644 index 57a63fa0b5d51..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/queries.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { esql, type EsqlRequest } from '@elastic/esql'; -import { ALERT_ACTIONS_DATA_STREAM } from '../../resources/alert_actions'; - -export const getBulkGetAlertActionsQuery = (episodeIds: string[]): EsqlRequest => { - const episodeIdValues = episodeIds.map((id) => esql.str(id)); - - return esql` - FROM ${ALERT_ACTIONS_DATA_STREAM} - | WHERE episode_id IN (${episodeIdValues}) - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "tag", "unsnooze") - | STATS - tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), - last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), - last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), - last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") - BY episode_id, rule_id, group_hash - | KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action, tags - `.toRequest(); -}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts index dd564212c5b15..79a2d7801b765 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts @@ -44,7 +44,6 @@ const mappings: MappingsDefinition = { notification_group_id: { type: 'keyword' }, source: { type: 'keyword' }, reason: { type: 'text' }, - tags: { type: 'keyword' }, }, }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/bulk_get_alert_actions_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/bulk_get_alert_actions_route.ts deleted file mode 100644 index 9f72ae143af18..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/bulk_get_alert_actions_route.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 Boom from '@hapi/boom'; -import { Request, Response, type RouteHandler } from '@kbn/core-di-server'; -import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; -import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; -import { inject, injectable } from 'inversify'; -import { - bulkGetAlertActionsBodySchema, - type BulkGetAlertActionsBody, -} from '@kbn/alerting-v2-schemas'; -import { AlertActionsClient } from '../../lib/alert_actions_client'; -import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; -import { INTERNAL_ALERTING_V2_ALERT_API_PATH } from '../constants'; - -@injectable() -export class BulkGetAlertActionsRoute implements RouteHandler { - static method = 'post' as const; - static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/action/_bulk_get`; - static security: RouteSecurity = { - authz: { - requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.read], - }, - }; - static options = { access: 'internal' } as const; - static validate = { - request: { - body: buildRouteValidationWithZod(bulkGetAlertActionsBodySchema), - }, - } as const; - - constructor( - @inject(Request) - private readonly request: KibanaRequest, - @inject(Response) private readonly response: KibanaResponseFactory, - @inject(AlertActionsClient) private readonly alertActionsClient: AlertActionsClient - ) {} - - async handle() { - try { - const results = await this.alertActionsClient.bulkGet(this.request.body.episode_ids); - - return this.response.ok({ body: results }); - } catch (e) { - const boom = Boom.isBoom(e) ? e : Boom.boomify(e); - return this.response.customError({ - statusCode: boom.output.statusCode, - body: boom.output.payload, - }); - } - } -} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index 74ff69efd97a8..a05f6518f3066 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -18,7 +18,6 @@ import { BulkEnableRulesRoute } from '../routes/rules/bulk_enable_rules_route'; import { BulkDisableRulesRoute } from '../routes/rules/bulk_disable_rules_route'; import { CreateAlertActionRoute } from '../routes/alert_actions/create_alert_action_route'; import { BulkCreateAlertActionRoute } from '../routes/alert_actions/bulk_create_alert_action_route'; -import { BulkGetAlertActionsRoute } from '../routes/alert_actions/bulk_get_alert_actions_route'; import { BulkActionNotificationPoliciesRoute } from '../routes/notification_policies/bulk_action_notification_policies_route'; import { CreateNotificationPolicyRoute } from '../routes/notification_policies/create_notification_policy_route'; import { DisableNotificationPolicyRoute } from '../routes/notification_policies/disable_notification_policy_route'; @@ -43,7 +42,6 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(BulkDisableRulesRoute); bind(Route).toConstantValue(CreateAlertActionRoute); bind(Route).toConstantValue(BulkCreateAlertActionRoute); - bind(Route).toConstantValue(BulkGetAlertActionsRoute); bind(Route).toConstantValue(CreateNotificationPolicyRoute); bind(Route).toConstantValue(GetNotificationPolicyRoute); bind(Route).toConstantValue(UpdateNotificationPolicyRoute); From 483575b3e47cab4020571bbcb7b57b2a324979d8 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 25 Mar 2026 14:13:07 +0100 Subject: [PATCH 03/28] Implements small UI fixes. --- .../actions}/acknowledge_action_button.tsx | 30 +++--- .../actions/alert_episode_actions_cell.tsx | 73 +++++++++++++ .../actions/alert_episode_tags.tsx | 76 +++++++++++++ .../actions}/deactivate_action_button.tsx | 32 +++--- .../actions/snooze_action_button.tsx | 39 +++++++ .../alert_episode_status_badge.test.tsx} | 14 +-- .../status/alert_episode_status_badge.tsx} | 14 +-- .../status/alert_episode_status_cell.test.tsx | 54 ++++++++++ .../status/alert_episode_status_cell.tsx | 42 ++++++++ .../components/alerting_episode_tags.tsx | 41 ------- .../components/snooze_action_button.tsx | 39 ------- .../hooks/use_fetch_episode_actions.ts | 24 ++--- .../types/episode_action.ts | 14 +-- .../public/pages/alerts_v2/alerts_v2.tsx | 101 ++++++++---------- 14 files changed, 389 insertions(+), 204 deletions(-) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{ => alert_episodes/actions}/acknowledge_action_button.tsx (53%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{ => alert_episodes/actions}/deactivate_action_button.tsx (52%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alerting_episode_status_badge.test.tsx => alert_episodes/status/alert_episode_status_badge.test.tsx} (72%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alerting_episode_status_badge.tsx => alert_episodes/status/alert_episode_status_badge.tsx} (78%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx delete mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx delete mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx similarity index 53% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx index 1a5f8ace734bf..7150bd255ed08 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -6,34 +6,34 @@ */ import React from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; export interface AcknowledgeActionButtonProps { - lastAckAction: string | null; + lastAckAction?: string | null; } export function AcknowledgeActionButton({ lastAckAction }: AcknowledgeActionButtonProps) { - const isAcknowledged = lastAckAction === 'ack'; + const isAcknowledged = !lastAckAction || lastAckAction === 'ack'; const label = isAcknowledged - ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.acknowledged', { - defaultMessage: 'Acknowledged', + ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', { + defaultMessage: 'Unacknowledge', }) - : i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledged', { - defaultMessage: 'Unacknowledged', + : i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.acknowledge', { + defaultMessage: 'Acknowledge', }); return ( - {}} - onClickAriaLabel={label} - data-test-subj="alertingEpisodeAcknowledgeActionButton" + data-test-subj="alertEpisodeAcknowledgeActionButton" > - - {label} - - + {label} + ); } diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx new file mode 100644 index 0000000000000..466a8afe3b657 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiListGroup, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AcknowledgeActionButton } from './acknowledge_action_button'; +import { SnoozeActionButton } from './snooze_action_button'; +import type { EpisodeAction } from '../../../types/episode_action'; +import { DeactivateActionButton } from './deactivate_action_button'; + +export interface AlertEpisodeActionsCellProps { + episodeAction?: EpisodeAction; +} + +export function AlertEpisodeActionsCell({ episodeAction }: AlertEpisodeActionsCellProps) { + const [isMoreOpen, setIsMoreOpen] = useState(false); + + return ( + + + + + + + + + setIsMoreOpen((open) => !open)} + data-test-subj="alertingEpisodeActionsMoreButton" + /> + } + isOpen={isMoreOpen} + closePopover={() => setIsMoreOpen(false)} + anchorPosition="downLeft" + panelPaddingSize="s" + > + + + + + + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx new file mode 100644 index 0000000000000..8d1bdcea60a66 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx @@ -0,0 +1,76 @@ +/* + * 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'; +import React, { useState } from 'react'; +import { EuiBadge, EuiPopover, EuiFlexGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export function AlertEpisodeTags({ + tags, + size = 3, + oneLine = false, +}: { + tags: string[]; + size?: number; + oneLine?: boolean; +}) { + const [isMoreTagsOpen, setIsMoreTagsOpen] = useState(false); + const onMoreTagsClick = (e: any) => { + e.stopPropagation(); + setIsMoreTagsOpen((isPopoverOpen) => !isPopoverOpen); + }; + const closePopover = () => setIsMoreTagsOpen(false); + const moreTags = tags.length > size && ( + + + + ); + + return ( + + {tags.slice(0, size).map((tag) => ( + + {tag} + + ))} + {oneLine ? ' ' :
} + + {tags.slice(size).map((tag) => ( + + {tag} + + ))} + +
+ ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx similarity index 52% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx index 49b61cbf8759b..376065e61156b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx @@ -6,37 +6,33 @@ */ import React from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { EuiListGroupItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; export interface DeactivateActionButtonProps { - lastDeactivateAction: string | null; + lastDeactivateAction?: string | null; } export function DeactivateActionButton({ lastDeactivateAction }: DeactivateActionButtonProps) { const isDeactivated = lastDeactivateAction === 'deactivate'; const label = isDeactivated - ? i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.deactivated', { - defaultMessage: 'Deactivated', + ? i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.activate', { + defaultMessage: 'Activate', }) - : i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.active', { - defaultMessage: 'Active', + : i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.deactivate', { + defaultMessage: 'Deactivate', }); + const iconType = isDeactivated ? 'check' : 'cross'; + return ( - {}} - onClickAriaLabel={label} - data-test-subj="alertingEpisodeDeactivateActionButton" - > - - {label} - - - - - + data-test-subj="alertingEpisodeActionsDeactivateActionButton" + /> ); } diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx new file mode 100644 index 0000000000000..071a71cac9469 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface SnoozeActionButtonProps { + lastSnoozeAction?: string | null; +} + +export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps) { + const isSnoozed = lastSnoozeAction === 'snooze'; + + const label = isSnoozed + ? i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { + defaultMessage: 'Unsnooze', + }) + : i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.snooze', { + defaultMessage: 'Snooze', + }); + + return ( + {}} + data-test-subj="alertEpisodeSnoozeActionButton" + > + {label} + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx similarity index 72% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx index 00b85ea742ca7..64ade263497af 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx @@ -7,32 +7,32 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { AlertingEpisodeStatusBadge } from './alerting_episode_status_badge'; +import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; -describe('AlertingEpisodeStatusBadge', () => { +describe('AlertEpisodeStatusBadge', () => { it('renders an inactive badge', () => { - const { getByText } = render(); + const { getByText } = render(); const badge = getByText('Inactive'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a pending badge', () => { - const { getByText } = render(); + const { getByText } = render(); const badge = getByText('Pending'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders an active badge', () => { - const { getByText } = render(); + const { getByText } = render(); const badge = getByText('Active'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a recovering badge', () => { - const { getByText } = render(); + const { getByText } = render(); const badge = getByText('Recovering'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); @@ -40,7 +40,7 @@ describe('AlertingEpisodeStatusBadge', () => { it('renders an unknown badge for unrecognized status', () => { // @ts-expect-error unknown status string - const { getByText } = render(); + const { getByText } = render(); const badge = getByText('Unknown'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx similarity index 78% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx index 905e583e62a8f..0c9e088dce674 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_status_badge.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx @@ -10,17 +10,17 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; -export interface AlertingEpisodeStatusBadgeProps { +export interface AlertEpisodeStatusBadgeProps { status: AlertEpisodeStatus; } /** * Renders a badge indicating the status of an alerting episode. */ -export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadgeProps) => { +export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps) => { if (status === 'inactive') { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.inactiveStatusBadgeLabel', { defaultMessage: 'Inactive', })} @@ -29,7 +29,7 @@ export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadg } if (status === 'pending') { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.pendingStatusBadgeLabel', { defaultMessage: 'Pending', })} @@ -38,7 +38,7 @@ export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadg } if (status === 'active') { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.activeStatusBadgeLabel', { defaultMessage: 'Active', })} @@ -47,7 +47,7 @@ export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadg } if (status === 'recovering') { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.recoveringStatusBadgeLabel', { defaultMessage: 'Recovering', })} @@ -55,7 +55,7 @@ export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadg ); } return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.unknownStatusBadgeLabel', { defaultMessage: 'Unknown', })} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx new file mode 100644 index 0000000000000..0e33d85e05ebd --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 } from '@testing-library/react'; +import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; + +describe('AlertEpisodeStatusCell', () => { + it('renders status badge only when no action indicators', () => { + const { getByText, container } = render(); + expect(getByText('Active')).toBeInTheDocument(); + expect(container.querySelector('[data-euiicon-type="bell"]')).not.toBeInTheDocument(); + }); + + it('renders snoozed bell when last snooze action is snooze', () => { + const { container } = render( + + ); + expect(container.querySelector('[data-euiicon-type="bell"]')).toBeInTheDocument(); + }); + + it('renders check icon when acknowledged', () => { + const { container } = render( + + ); + expect(container.querySelector('[data-euiicon-type="checkCircle"]')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx new file mode 100644 index 0000000000000..38324168cf033 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; +import type { EpisodeAction } from '../../../types/episode_action'; +import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; + +export interface AlertEpisodeStatusCellProps { + status: AlertEpisodeStatus; + episodeAction?: EpisodeAction; +} + +export function AlertEpisodeStatusCell({ status, episodeAction }: AlertEpisodeStatusCellProps) { + const isAcknowledged = episodeAction?.lastAckAction === 'ack'; + const isSnoozed = episodeAction?.lastSnoozeAction === 'snooze'; + + return ( + + + + + {isSnoozed && ( + + + + )} + {isAcknowledged && ( + + + + )} + + ); +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx deleted file mode 100644 index 198cd8fcb4ba8..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alerting_episode_tags.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; - -export function AlertingEpisodeTags({ - tags, - color, - size = 3, - oneLine = false, -}: { - tags?: string[] | null; - color?: string; - size?: number; - oneLine?: boolean; -}) { - if (!tags?.length) { - return -; - } - - return ( - - {tags.slice(0, size).map((tag, index) => ( - - {tag} - - ))} - - ); -} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx deleted file mode 100644 index 1c7488f8a558e..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/snooze_action_button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { EuiButtonIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export interface SnoozeActionButtonProps { - lastSnoozeAction: string | null; -} - -export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps) { - const isSnoozed = lastSnoozeAction === 'snooze'; - - return isSnoozed ? ( - {}} - data-test-subj="alertingEpisodeSnoozeActionButton" - /> - ) : ( - {}} - data-test-subj="alertingEpisodeSnoozeActionButton" - /> - ); -} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index 76b18ff262870..79f1ac61a4598 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -11,14 +11,14 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { EpisodeAction } from '../types/episode_action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; -const ALERT_ACTIONS_DATA_STREAM = '.alerting-actions'; +const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; const escapeEsqlString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -const tagsFromRow = (value: unknown): string[] | null => { +const tagsFromRow = (value: unknown): string[] => { if (value == null) { - return null; + return []; } if (typeof value === 'string') { return [value]; @@ -26,7 +26,7 @@ const tagsFromRow = (value: unknown): string[] | null => { if (Array.isArray(value)) { return value as string[]; } - return null; + return []; }; const buildBulkGetAlertActionsQuery = (episodeIds: string[]): string => { @@ -65,12 +65,12 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode return result.rows.map( (row): EpisodeAction => ({ - episode_id: row.episode_id as string, - rule_id: (row.rule_id as string) ?? null, - group_hash: (row.group_hash as string) ?? null, - last_ack_action: (row.last_ack_action as string) ?? null, - last_deactivate_action: (row.last_deactivate_action as string) ?? null, - last_snooze_action: (row.last_snooze_action as string) ?? null, + episodeId: row.episode_id as string, + ruleId: (row.rule_id as string) ?? null, + groupHash: (row.group_hash as string) ?? null, + lastAckAction: (row.last_ack_action as string) ?? null, + lastDeactivateAction: (row.last_deactivate_action as string) ?? null, + lastSnoozeAction: (row.last_snooze_action as string) ?? null, tags: tagsFromRow(row.tags), }) ); @@ -83,11 +83,11 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode const map = new Map(); if (data) { for (const action of data) { - map.set(action.episode_id, action); + map.set(action.episodeId, action); } } return map; }, [data]); - return { data: data ?? [], actionsMap, isLoading }; + return { actionsMap, isLoading }; }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts index 66dab324bbfa8..64ced17f530d0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts @@ -6,11 +6,11 @@ */ export interface EpisodeAction { - episode_id: string; - rule_id: string | null; - group_hash: string | null; - last_ack_action: string | null; - last_deactivate_action: string | null; - last_snooze_action: string | null; - tags: string[] | null; + episodeId: string; + ruleId: string | null; + groupHash: string | null; + lastAckAction: string | null; + lastDeactivateAction: string | null; + lastSnoozeAction: string | null; + tags: string[]; } diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index b26fbab005b76..bbb01afc4ef11 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useMemo, useState } from 'react'; +import type { EuiThemeComputed } from '@elastic/eui'; import { EuiCode, EuiFlexGroup, @@ -29,29 +30,51 @@ import { css } from '@emotion/react'; import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query'; import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; -import { AlertingEpisodeStatusBadge } from '@kbn/alerting-v2-episodes-ui/components/alerting_episode_status_badge'; -import { AlertingEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alerting_episode_tags'; -import { AcknowledgeActionButton } from '@kbn/alerting-v2-episodes-ui/components/acknowledge_action_button'; -import { SnoozeActionButton } from '@kbn/alerting-v2-episodes-ui/components/snooze_action_button'; -import { DeactivateActionButton } from '@kbn/alerting-v2-episodes-ui/components/deactivate_action_button'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; import { useFetchEpisodeActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions'; +import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell'; +import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell'; +import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags'; import { useKibana } from '../../utils/kibana_react'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { HeaderMenu } from '../overview/components/header_menu/header_menu'; const PAGE_SIZE = 50; -/** Narrow columns so `rule.id` / time keep flexible width */ const ALERTS_V2_TABLE_SETTINGS: UnifiedDataTableSettings = { columns: { duration: { width: 100 }, - 'episode.status': { width: 128 }, - state: { width: 280 }, - notify: { width: 80 }, + actions: { width: 320 }, + 'episode.status': { width: 220 }, }, }; +const getTableCss = (euiTheme: EuiThemeComputed) => css` + height: 100%; + border-radius: ${euiTheme.border.radius.medium}; + border: ${euiTheme.border.thin}; + overflow: hidden; + + & .unifiedDataTable__cellValue { + font-family: unset; + } + + & .euiDataGridRowCell__content { + display: flex; + align-items: center; + } + + & .euiDataGridRowCell[data-gridcell-column-id='select'] .euiDataGridRowCell__content { + align-items: center; + justify-content: flex-start; + height: 100%; + } + + & .euiDataGridRowCell[data-gridcell-column-id='actions'] .euiDataGridRowCell__content { + justify-content: flex-end; + } +`; + function EmptyToolbar() { return <>; } @@ -63,13 +86,12 @@ export function AlertsV2Page() { const [sort] = useState([['@timestamp', 'desc']]); const [columns, setColumns] = useState([ + 'episode.status', '@timestamp', 'rule.id', - 'episode.status', - 'notify', 'duration', 'tags', - 'state', + 'actions', ]); const [rowHeight, setRowHeight] = useState(2); @@ -161,16 +183,7 @@ export function AlertsV2Page() { ({ - ...column, - displayAsText: i18n.translate( - 'xpack.observability.alertsV2.columns.currentState', - { - defaultMessage: 'Current state', - } - ), - }), - notify: ({ column }) => ({ + actions: ({ column }) => ({ ...column, - displayAsText: i18n.translate('xpack.observability.alertsV2.columns.notify', { - defaultMessage: 'Notify', + displayAsText: i18n.translate('xpack.observability.alertsV2.columns.actions', { + defaultMessage: 'Actions', }), }), tags: ({ column }) => ({ @@ -208,40 +212,21 @@ export function AlertsV2Page() { externalCustomRenderers={{ 'episode.status': (props) => { const status = props.row.flattened[props.columnId] as AlertEpisodeStatus; - return ; - }, - state: (props) => { const episodeId = props.row.flattened['episode.id'] as string; const episodeAction = actionsMap.get(episodeId); - return ( - - - - - - - - - ); + + return ; }, - notify: (props) => { + actions: (props) => { const episodeId = props.row.flattened['episode.id'] as string; const episodeAction = actionsMap.get(episodeId); - return ( - - ); + return ; }, tags: (props) => { const episodeId = props.row.flattened['episode.id'] as string; const episodeAction = actionsMap.get(episodeId); - return ; + return ; }, 'rule.id': (props) => { if (!Object.keys(rulesIndex).length && isLoadingRules) { From c9c9e6df43d3e050125d877fe4cfa9e1f3dc6067 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 25 Mar 2026 16:02:36 +0100 Subject: [PATCH 04/28] Add unit tests. --- .../acknowledge_action_button.test.tsx | 48 ++++ .../actions/acknowledge_action_button.tsx | 0 .../alert_episode_actions_cell.test.tsx | 62 ++++++ .../actions/alert_episode_actions_cell.tsx | 0 .../actions/alert_episode_tags.test.tsx | 48 ++++ .../actions/alert_episode_tags.tsx | 0 .../actions/deactivate_action_button.test.tsx | 26 +++ .../actions/deactivate_action_button.tsx | 0 .../actions/snooze_action_button.test.tsx | 32 +++ .../actions/snooze_action_button.tsx | 0 .../alert_episode_status_badge.test.tsx | 22 +- .../status/alert_episode_status_badge.tsx | 0 .../status/alert_episode_status_cell.test.tsx | 26 ++- .../status/alert_episode_status_cell.tsx | 18 +- .../hooks/use_fetch_episode_actions.test.tsx | 206 ++++++++++++++++++ .../public/pages/alerts_v2/alerts_v2.tsx | 6 +- 16 files changed, 465 insertions(+), 29 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/actions/acknowledge_action_button.tsx (100%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/actions/alert_episode_actions_cell.tsx (100%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/actions/alert_episode_tags.tsx (100%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/actions/deactivate_action_button.tsx (100%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/actions/snooze_action_button.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/status/alert_episode_status_badge.test.tsx (66%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/status/alert_episode_status_badge.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/status/alert_episode_status_cell.test.tsx (55%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert_episodes => alert-episodes}/status/alert_episode_status_cell.tsx (75%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx new file mode 100644 index 0000000000000..a2ffe52e27237 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AcknowledgeActionButton } from './acknowledge_action_button'; + +describe('AcknowledgeActionButton', () => { + it('renders Unacknowledge when lastAckAction is undefined (treated as acknowledged)', () => { + render(); + expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( + 'Unacknowledge' + ); + expect( + screen + .getByTestId('alertEpisodeAcknowledgeActionButton') + .querySelector('[data-euiicon-type="crossCircle"]') + ).toBeInTheDocument(); + }); + + it('renders Unacknowledge when lastAckAction is ack', () => { + render(); + expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( + 'Unacknowledge' + ); + expect( + screen + .getByTestId('alertEpisodeAcknowledgeActionButton') + .querySelector('[data-euiicon-type="crossCircle"]') + ).toBeInTheDocument(); + }); + + it('renders Acknowledge when lastAckAction is unack', () => { + render(); + expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( + 'Acknowledge' + ); + expect( + screen + .getByTestId('alertEpisodeAcknowledgeActionButton') + .querySelector('[data-euiicon-type="checkCircle"]') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx new file mode 100644 index 0000000000000..9a2cde1b83cc0 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AlertEpisodeActionsCell } from './alert_episode_actions_cell'; + +describe('AlertEpisodeActionsCell', () => { + it('renders acknowledge, snooze, and more-actions controls', () => { + render(); + expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toBeInTheDocument(); + expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toBeInTheDocument(); + expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument(); + }); + + it('opens popover and shows Deactivate from episode action state', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); + expect( + await screen.findByTestId('alertingEpisodeActionsDeactivateActionButton') + ).toHaveTextContent('Deactivate'); + }); + + it('shows Activate in popover when episode is deactivated', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); + expect( + await screen.findByTestId('alertingEpisodeActionsDeactivateActionButton') + ).toHaveTextContent('Activate'); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx new file mode 100644 index 0000000000000..c5872f45b2fbb --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AlertEpisodeTags } from './alert_episode_tags'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +describe('AlertEpisodeTags', () => { + it('renders visible tags up to size', () => { + render( + + + + ); + expect(screen.getByText('alpha')).toBeInTheDocument(); + expect(screen.getByText('beta')).toBeInTheDocument(); + }); + + it('renders overflow count badge when tags exceed size', () => { + render( + + + + ); + expect(screen.getByText('a')).toBeInTheDocument(); + expect(screen.getByText('b')).toBeInTheDocument(); + expect(screen.getByText('c')).toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('opens popover with remaining tags when overflow badge is clicked', async () => { + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByText('+2')); + expect(await screen.findByText('c')).toBeInTheDocument(); + expect(screen.getByText('d')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx new file mode 100644 index 0000000000000..2287efa206189 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx @@ -0,0 +1,26 @@ +/* + * 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, screen } from '@testing-library/react'; +import { DeactivateActionButton } from './deactivate_action_button'; + +describe('DeactivateActionButton', () => { + it('renders Deactivate when not deactivated', () => { + render(); + expect(screen.getByTestId('alertingEpisodeActionsDeactivateActionButton')).toHaveTextContent( + 'Deactivate' + ); + }); + + it('renders Activate when deactivated', () => { + render(); + expect(screen.getByTestId('alertingEpisodeActionsDeactivateActionButton')).toHaveTextContent( + 'Activate' + ); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx new file mode 100644 index 0000000000000..fef9bd40a9fdf --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx @@ -0,0 +1,32 @@ +/* + * 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, screen } from '@testing-library/react'; +import { SnoozeActionButton } from './snooze_action_button'; + +describe('SnoozeActionButton', () => { + it('renders Snooze with bellSlash when not snoozed', () => { + render(); + expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); + expect( + screen + .getByTestId('alertEpisodeSnoozeActionButton') + .querySelector('[data-euiicon-type="bellSlash"]') + ).toBeInTheDocument(); + }); + + it('renders Unsnooze with bell when snoozed', () => { + render(); + expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Unsnooze'); + expect( + screen + .getByTestId('alertEpisodeSnoozeActionButton') + .querySelector('[data-euiicon-type="bell"]') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.test.tsx similarity index 66% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.test.tsx index 64ade263497af..3c155c61b9f08 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.test.tsx @@ -6,42 +6,42 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; describe('AlertEpisodeStatusBadge', () => { it('renders an inactive badge', () => { - const { getByText } = render(); - const badge = getByText('Inactive'); + render(); + const badge = screen.getByText('Inactive'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a pending badge', () => { - const { getByText } = render(); - const badge = getByText('Pending'); + render(); + const badge = screen.getByText('Pending'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders an active badge', () => { - const { getByText } = render(); - const badge = getByText('Active'); + render(); + const badge = screen.getByText('Active'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a recovering badge', () => { - const { getByText } = render(); - const badge = getByText('Recovering'); + render(); + const badge = screen.getByText('Recovering'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders an unknown badge for unrecognized status', () => { // @ts-expect-error unknown status string - const { getByText } = render(); - const badge = getByText('Unknown'); + render(); + const badge = screen.getByText('Unknown'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.test.tsx similarity index 55% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.test.tsx index 0e33d85e05ebd..c075823cb8363 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.test.tsx @@ -6,18 +6,20 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; describe('AlertEpisodeStatusCell', () => { it('renders status badge only when no action indicators', () => { - const { getByText, container } = render(); - expect(getByText('Active')).toBeInTheDocument(); - expect(container.querySelector('[data-euiicon-type="bell"]')).not.toBeInTheDocument(); + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByTestId('alertEpisodeStatusCell')).toBeInTheDocument(); + expect(screen.queryByTestId('alertEpisodeStatusCellSnoozeIndicator')).not.toBeInTheDocument(); + expect(screen.queryByTestId('alertEpisodeStatusCellAckIndicator')).not.toBeInTheDocument(); }); - it('renders snoozed bell when last snooze action is snooze', () => { - const { container } = render( + it('renders snoozed bellSlash badge when last snooze action is snooze', () => { + render( { lastAckAction: null, lastSnoozeAction: 'snooze', lastDeactivateAction: null, - tags: null, + tags: [], }} /> ); - expect(container.querySelector('[data-euiicon-type="bell"]')).toBeInTheDocument(); + expect(screen.getByTestId('alertEpisodeStatusCellSnoozeIndicator')).toBeInTheDocument(); }); - it('renders check icon when acknowledged', () => { - const { container } = render( + it('renders checkCircle badge when acknowledged', () => { + render( { lastAckAction: 'ack', lastSnoozeAction: null, lastDeactivateAction: null, - tags: null, + tags: [], }} /> ); - expect(container.querySelector('[data-euiicon-type="checkCircle"]')).toBeInTheDocument(); + expect(screen.getByTestId('alertEpisodeStatusCellAckIndicator')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.tsx similarity index 75% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.tsx index 38324168cf033..6f98bcfe76d18 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.tsx @@ -21,7 +21,13 @@ export function AlertEpisodeStatusCell({ status, episodeAction }: AlertEpisodeSt const isSnoozed = episodeAction?.lastSnoozeAction === 'snooze'; return ( - + {isSnoozed && ( - + )} {isAcknowledged && ( - + )} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx new file mode 100644 index 0000000000000..5a0ebe4d187bd --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -0,0 +1,206 @@ +/* + * 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, { type PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { useFetchEpisodeActions } from './use_fetch_episode_actions'; + +jest.mock('../utils/execute_esql_query'); + +const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); +const mockExpressions = {} as ExpressionsStart; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useFetchEpisodeActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('does not call ES|QL when episodeIds is empty', () => { + renderHook( + () => + useFetchEpisodeActions({ + episodeIds: [], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + expect(executeEsqlQueryMock).not.toHaveBeenCalled(); + }); + + it('fetches and builds actionsMap keyed by episode id', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + episode_id: 'ep-1', + rule_id: 'rule-1', + group_hash: 'gh-1', + last_ack_action: 'ack', + last_deactivate_action: null, + last_snooze_action: 'snooze', + tags: ['t1', 't2'], + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['ep-1'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + const call = executeEsqlQueryMock.mock.calls[0][0]; + expect(call.query).toContain('ep-1'); + expect(call.expressions).toBe(mockExpressions); + + const action = result.current.actionsMap.get('ep-1'); + expect(action).toEqual({ + episodeId: 'ep-1', + ruleId: 'rule-1', + groupHash: 'gh-1', + lastAckAction: 'ack', + lastDeactivateAction: null, + lastSnoozeAction: 'snooze', + tags: ['t1', 't2'], + }); + }); + + it('normalizes string tags into a single-element array', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + episode_id: 'ep-2', + rule_id: null, + group_hash: null, + last_ack_action: null, + last_deactivate_action: null, + last_snooze_action: null, + tags: 'solo', + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['ep-2'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.actionsMap.has('ep-2')).toBe(true)); + expect(result.current.actionsMap.get('ep-2')?.tags).toEqual(['solo']); + }); + + it('converts tags to empty arraywhen row tags are null', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + episode_id: 'ep-3', + rule_id: null, + group_hash: null, + last_ack_action: null, + last_deactivate_action: null, + last_snooze_action: null, + tags: null, + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['ep-3'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.actionsMap.has('ep-3')).toBe(true)); + expect(result.current.actionsMap.get('ep-3')?.tags).toEqual([]); + }); + + it('escapes quotes in episode ids in the generated query', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [], + } as unknown as Awaited>); + + renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['say"cheese'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(executeEsqlQueryMock).toHaveBeenCalled()); + const query = executeEsqlQueryMock.mock.calls[0][0].query; + expect(query).toContain('\\"'); + }); + + it('keeps the last row when duplicate episode ids are returned', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + episode_id: 'dup', + rule_id: 'r1', + group_hash: null, + last_ack_action: 'ack', + last_deactivate_action: null, + last_snooze_action: null, + tags: [], + }, + { + episode_id: 'dup', + rule_id: 'r2', + group_hash: null, + last_ack_action: 'unack', + last_deactivate_action: null, + last_snooze_action: null, + tags: [], + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['dup'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.actionsMap.get('dup')?.ruleId).toBe('r2')); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index bbb01afc4ef11..6a54063b480ac 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -32,9 +32,9 @@ import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hook import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; import { useFetchEpisodeActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions'; -import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell'; -import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell'; -import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags'; +import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell'; +import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell'; +import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags'; import { useKibana } from '../../utils/kibana_react'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { HeaderMenu } from '../overview/components/header_menu/header_menu'; From f1fcb016053a1589f649f88bd8a75fb420c06481 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Thu, 26 Mar 2026 10:18:27 +0100 Subject: [PATCH 05/28] Address PR comment. Rename deactivate to resolve in the UI. --- .../actions/acknowledge_action_button.test.tsx | 4 ++-- .../actions/acknowledge_action_button.tsx | 2 +- .../actions/alert_episode_actions_cell.test.tsx | 4 ++-- .../actions/alert_episode_actions_cell.tsx | 4 ++-- .../actions/deactivate_action_button.test.tsx | 16 ++++++++-------- .../actions/deactivate_action_button.tsx | 14 +++++++------- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx index a2ffe52e27237..7710869b195b9 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx @@ -13,12 +13,12 @@ describe('AcknowledgeActionButton', () => { it('renders Unacknowledge when lastAckAction is undefined (treated as acknowledged)', () => { render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( - 'Unacknowledge' + 'Acknowledge' ); expect( screen .getByTestId('alertEpisodeAcknowledgeActionButton') - .querySelector('[data-euiicon-type="crossCircle"]') + .querySelector('[data-euiicon-type="checkCircle"]') ).toBeInTheDocument(); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx index 7150bd255ed08..1abe695d61125 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx @@ -14,7 +14,7 @@ export interface AcknowledgeActionButtonProps { } export function AcknowledgeActionButton({ lastAckAction }: AcknowledgeActionButtonProps) { - const isAcknowledged = !lastAckAction || lastAckAction === 'ack'; + const isAcknowledged = lastAckAction === 'ack'; const label = isAcknowledged ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx index 9a2cde1b83cc0..0141c4c03d9d3 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx @@ -35,7 +35,7 @@ describe('AlertEpisodeActionsCell', () => { ); await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( - await screen.findByTestId('alertingEpisodeActionsDeactivateActionButton') + await screen.findByTestId('alertingEpisodeActionsResolveActionButton') ).toHaveTextContent('Deactivate'); }); @@ -56,7 +56,7 @@ describe('AlertEpisodeActionsCell', () => { ); await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( - await screen.findByTestId('alertingEpisodeActionsDeactivateActionButton') + await screen.findByTestId('alertingEpisodeActionsResolveActionButton') ).toHaveTextContent('Activate'); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx index 466a8afe3b657..ee847f63075cc 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { SnoozeActionButton } from './snooze_action_button'; import type { EpisodeAction } from '../../../types/episode_action'; -import { DeactivateActionButton } from './deactivate_action_button'; +import { ResolveActionButton } from './deactivate_action_button'; export interface AlertEpisodeActionsCellProps { episodeAction?: EpisodeAction; @@ -64,7 +64,7 @@ export function AlertEpisodeActionsCell({ episodeAction }: AlertEpisodeActionsCe panelPaddingSize="s" > - + diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx index 2287efa206189..750d38b71e17f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx @@ -7,20 +7,20 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { DeactivateActionButton } from './deactivate_action_button'; +import { ResolveActionButton } from './deactivate_action_button'; -describe('DeactivateActionButton', () => { +describe('ResolveActionButton', () => { it('renders Deactivate when not deactivated', () => { - render(); - expect(screen.getByTestId('alertingEpisodeActionsDeactivateActionButton')).toHaveTextContent( - 'Deactivate' + render(); + expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( + 'Unresolve' ); }); it('renders Activate when deactivated', () => { - render(); - expect(screen.getByTestId('alertingEpisodeActionsDeactivateActionButton')).toHaveTextContent( - 'Activate' + render(); + expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( + 'Resolve' ); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx index 376065e61156b..3aa04cc3176dd 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx @@ -9,19 +9,19 @@ import React from 'react'; import { EuiListGroupItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export interface DeactivateActionButtonProps { +export interface ResolveActionButtonProps { lastDeactivateAction?: string | null; } -export function DeactivateActionButton({ lastDeactivateAction }: DeactivateActionButtonProps) { +export function ResolveActionButton({ lastDeactivateAction }: ResolveActionButtonProps) { const isDeactivated = lastDeactivateAction === 'deactivate'; const label = isDeactivated - ? i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.activate', { - defaultMessage: 'Activate', + ? i18n.translate('xpack.alertingV2.episodesUi.resolveAction.activate', { + defaultMessage: 'Resolve', }) - : i18n.translate('xpack.alertingV2.episodesUi.deactivateAction.deactivate', { - defaultMessage: 'Deactivate', + : i18n.translate('xpack.alertingV2.episodesUi.resolveAction.deactivate', { + defaultMessage: 'Unresolve', }); const iconType = isDeactivated ? 'check' : 'cross'; @@ -32,7 +32,7 @@ export function DeactivateActionButton({ lastDeactivateAction }: DeactivateActio size="s" iconType={iconType} onClick={() => {}} - data-test-subj="alertingEpisodeActionsDeactivateActionButton" + data-test-subj="alertingEpisodeActionsResolveActionButton" /> ); } From 38357d4dbd34d2a54f672f35b25a7432f3bcd994 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Thu, 26 Mar 2026 14:10:03 +0100 Subject: [PATCH 06/28] Fix file path to use snake_case. --- .../actions/acknowledge_action_button.test.tsx | 0 .../actions/acknowledge_action_button.tsx | 0 .../actions/alert_episode_actions_cell.test.tsx | 0 .../actions/alert_episode_actions_cell.tsx | 0 .../actions/alert_episode_tags.test.tsx | 0 .../actions/alert_episode_tags.tsx | 0 .../actions/deactivate_action_button.test.tsx | 0 .../actions/deactivate_action_button.tsx | 0 .../actions/snooze_action_button.test.tsx | 0 .../actions/snooze_action_button.tsx | 0 .../status/alert_episode_status_badge.test.tsx | 0 .../status/alert_episode_status_badge.tsx | 0 .../status/alert_episode_status_cell.test.tsx | 0 .../status/alert_episode_status_cell.tsx | 0 .../observability/public/pages/alerts_v2/alerts_v2.tsx | 6 +++--- 15 files changed, 3 insertions(+), 3 deletions(-) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/acknowledge_action_button.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/acknowledge_action_button.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/alert_episode_actions_cell.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/alert_episode_actions_cell.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/alert_episode_tags.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/alert_episode_tags.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/deactivate_action_button.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/deactivate_action_button.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/snooze_action_button.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/actions/snooze_action_button.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/status/alert_episode_status_badge.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/status/alert_episode_status_badge.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/status/alert_episode_status_cell.test.tsx (100%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/{alert-episodes => alert_episodes}/status/alert_episode_status_cell.tsx (100%) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/acknowledge_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/deactivate_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/actions/snooze_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_badge.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx similarity index 100% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index 6a54063b480ac..bbb01afc4ef11 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -32,9 +32,9 @@ import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hook import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; import { useFetchEpisodeActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions'; -import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/status/alert_episode_status_cell'; -import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_actions_cell'; -import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert-episodes/actions/alert_episode_tags'; +import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell'; +import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell'; +import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags'; import { useKibana } from '../../utils/kibana_react'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { HeaderMenu } from '../overview/components/header_menu/header_menu'; From 9af9e6a355c928d4bc8a85f6ce5e41febe971f0b Mon Sep 17 00:00:00 2001 From: adcoelho Date: Thu, 26 Mar 2026 14:45:39 +0100 Subject: [PATCH 07/28] Add snooze popover to episodes table. --- .../alert_episode_snooze_form.test.tsx | 62 ++++++ .../actions/alert_episode_snooze_form.tsx | 184 ++++++++++++++++++ .../actions/snooze_action_button.test.tsx | 42 +++- .../actions/snooze_action_button.tsx | 49 +++-- 4 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx new file mode 100644 index 0000000000000..550d63cc6852d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { AlertEpisodeSnoozeForm, computeEpisodeSnoozedUntil } from './alert_episode_snooze_form'; + +describe('AlertEpisodeSnoozeForm', () => { + it('computeEpisodeSnoozedUntil returns a future ISO date', () => { + const before = Date.now(); + const result = computeEpisodeSnoozedUntil(1, 'h'); + const after = Date.now(); + const parsed = Date.parse(result); + + expect(Number.isNaN(parsed)).toBe(false); + expect(parsed).toBeGreaterThanOrEqual(before + 3_600_000); + expect(parsed).toBeLessThanOrEqual(after + 3_600_000 + 1_000); + }); + + it('applies preset snooze duration when a preset is clicked', async () => { + const user = userEvent.setup(); + const onApplySnooze = jest.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: '1 hour' })); + + expect(onApplySnooze).toHaveBeenCalledTimes(1); + expect(Number.isNaN(Date.parse(onApplySnooze.mock.calls[0][0]))).toBe(false); + }); + + it('shows cancel button only when isSnoozed is true', () => { + const { rerender } = render( + + ); + expect(screen.queryByRole('button', { name: 'Cancel snooze' })).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByRole('button', { name: 'Cancel snooze' })).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx new file mode 100644 index 0000000000000..4c4e39da0f666 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx @@ -0,0 +1,184 @@ +/* + * 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 { + EuiButton, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +export type AlertEpisodeDurationUnit = 'm' | 'h' | 'd'; + +export const computeEpisodeSnoozedUntil = ( + value: number, + unit: AlertEpisodeDurationUnit +): string => { + const ms: Record = { m: 60_000, h: 3_600_000, d: 86_400_000 }; + return new Date(Date.now() + value * ms[unit]).toISOString(); +}; + +export const ALERT_EPISODE_UNIT_OPTIONS: Array<{ value: AlertEpisodeDurationUnit; text: string }> = + [ + { + value: 'm', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.minutes', { + defaultMessage: 'Minutes', + }), + }, + { + value: 'h', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.hours', { + defaultMessage: 'Hours', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.days', { + defaultMessage: 'Days', + }), + }, + ]; + +export const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ + label: string; + value: number; + unit: AlertEpisodeDurationUnit; +}> = [ + { + label: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.preset.1h', { + defaultMessage: '1 hour', + }), + value: 1, + unit: 'h', + }, + { + label: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.preset.3h', { + defaultMessage: '3 hours', + }), + value: 3, + unit: 'h', + }, + { + label: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.preset.8h', { + defaultMessage: '8 hours', + }), + value: 8, + unit: 'h', + }, + { + label: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.preset.1d', { + defaultMessage: '1 day', + }), + value: 1, + unit: 'd', + }, +]; + +interface AlertEpisodeSnoozeFormProps { + isSnoozed: boolean; + onApplySnooze: (snoozedUntil: string) => void; + onCancelSnooze: () => void; +} + +export const AlertEpisodeSnoozeForm = ({ + isSnoozed, + onApplySnooze, + onCancelSnooze, +}: AlertEpisodeSnoozeFormProps) => { + const [durationValue, setDurationValue] = useState(1); + const [durationUnit, setDurationUnit] = useState('h'); + + const applySnooze = (value: number, unit: AlertEpisodeDurationUnit) => { + onApplySnooze(computeEpisodeSnoozedUntil(value, unit)); + }; + + return ( +
+ +

+ {i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.title', { + defaultMessage: 'Snooze notifications', + })} +

+
+ + + + setDurationValue(Math.max(1, parseInt(e.target.value, 10) || 1))} + compressed + aria-label={i18n.translate( + 'xpack.alertingV2.episodesUi.snoozeForm.durationValueAriaLabel', + { + defaultMessage: 'Snooze duration value', + } + )} + /> + + + setDurationUnit(e.target.value as AlertEpisodeDurationUnit)} + compressed + aria-label={i18n.translate( + 'xpack.alertingV2.episodesUi.snoozeForm.unitSelectAriaLabel', + { + defaultMessage: 'Snooze duration unit', + } + )} + /> + + + applySnooze(durationValue, durationUnit)}> + {i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.apply', { + defaultMessage: 'Apply', + })} + + + + + + + + {i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.commonlyUsed', { + defaultMessage: 'Commonly used', + })} + + + + {ALERT_EPISODE_COMMON_SNOOZE_TIMES.map((preset) => ( + + applySnooze(preset.value, preset.unit)}>{preset.label} + + ))} + + + {isSnoozed && ( + <> + + + {i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.cancel', { + defaultMessage: 'Cancel snooze', + })} + + + )} +
+ ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx index fef9bd40a9fdf..ed56cd3d62286 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; import { SnoozeActionButton } from './snooze_action_button'; describe('SnoozeActionButton', () => { @@ -29,4 +30,43 @@ describe('SnoozeActionButton', () => { .querySelector('[data-euiicon-type="bell"]') ).toBeInTheDocument(); }); + + it('opens popover with snooze form content on click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); + + expect(screen.getByTestId('alertEpisodeSnoozeForm')).toBeInTheDocument(); + expect(screen.getByLabelText('Snooze duration value')).toBeInTheDocument(); + expect(screen.getByLabelText('Snooze duration unit')).toBeInTheDocument(); + }); + + it('closes popover after clicking Apply', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); + await user.click(screen.getByRole('button', { name: 'Apply' })); + + await waitFor(() => + expect(screen.queryByTestId('alertEpisodeSnoozeForm')).not.toBeInTheDocument() + ); + }); + + it('shows cancel snooze and closes popover after click when snoozed', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); + + const cancelButton = screen.getByRole('button', { name: 'Cancel snooze' }); + expect(cancelButton).toBeInTheDocument(); + + await user.click(cancelButton); + + await waitFor(() => + expect(screen.queryByTestId('alertEpisodeSnoozeForm')).not.toBeInTheDocument() + ); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index 071a71cac9469..21091428990e1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -5,16 +5,20 @@ * 2.0. */ -import React from 'react'; -import { EuiButton } from '@elastic/eui'; +import React, { useState } from 'react'; +import { EuiButton, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { AlertEpisodeSnoozeForm } from './alert_episode_snooze_form'; export interface SnoozeActionButtonProps { lastSnoozeAction?: string | null; } export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const isSnoozed = lastSnoozeAction === 'snooze'; + const togglePopover = () => setIsPopoverOpen((prev) => !prev); + const closePopover = () => setIsPopoverOpen(false); const label = isSnoozed ? i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { @@ -25,15 +29,38 @@ export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps }); return ( - {}} - data-test-subj="alertEpisodeSnoozeActionButton" + + {label} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="downLeft" + panelPaddingSize="m" + panelStyle={{ width: 320 }} > - {label} - + { + closePopover(); + }} + onCancelSnooze={() => { + closePopover(); + }} + /> + ); } From cf1b96fe7199217f82ab6dd274b96ed1c2d16973 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Thu, 26 Mar 2026 15:16:14 +0100 Subject: [PATCH 08/28] Create per action type routes for alerting v2. --- .../acknowledge_action_button.test.tsx | 42 +++++++- .../actions/acknowledge_action_button.tsx | 27 ++++- .../alert_episode_actions_cell.test.tsx | 21 +++- .../actions/alert_episode_actions_cell.tsx | 23 +++- .../actions/alert_episode_snooze_form.tsx | 2 +- .../actions/deactivate_action_button.test.tsx | 33 +++++- .../actions/deactivate_action_button.tsx | 28 ++++- .../actions/snooze_action_button.test.tsx | 41 ++++++- .../actions/snooze_action_button.tsx | 31 +++++- .../hooks/use_create_alert_action.ts | 45 ++++++++ .../hooks/use_fetch_episode_actions.ts | 3 +- .../alerting-v2-episodes-ui/query_keys.ts | 2 + .../src/alert_action_schema.ts | 34 ++++++ .../create_ack_alert_action_route.test.ts | 74 +++++++++++++ .../create_ack_alert_action_route.ts | 15 +++ ...create_activate_alert_action_route.test.ts | 75 +++++++++++++ .../create_activate_alert_action_route.ts | 15 +++ ...create_alert_action_route_for_type.test.ts | 100 ++++++++++++++++++ .../create_alert_action_route_for_type.ts | 93 ++++++++++++++++ ...eate_deactivate_alert_action_route.test.ts | 75 +++++++++++++ .../create_deactivate_alert_action_route.ts | 15 +++ .../create_snooze_alert_action_route.test.ts | 100 ++++++++++++++++++ .../create_snooze_alert_action_route.ts | 15 +++ .../create_tag_alert_action_route.test.ts | 74 +++++++++++++ .../create_tag_alert_action_route.ts | 15 +++ .../create_unack_alert_action_route.test.ts | 75 +++++++++++++ .../create_unack_alert_action_route.ts | 15 +++ ...create_unsnooze_alert_action_route.test.ts | 74 +++++++++++++ .../create_unsnooze_alert_action_route.ts | 15 +++ .../alerting_v2/server/setup/bind_routes.ts | 14 +++ .../public/pages/alerts_v2/alerts_v2.tsx | 2 +- 31 files changed, 1167 insertions(+), 26 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx index 7710869b195b9..0a6ef90a8fc23 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx @@ -7,11 +7,27 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { AcknowledgeActionButton } from './acknowledge_action_button'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); +const mockServices = { http: {} as any }; describe('AcknowledgeActionButton', () => { + const mutate = jest.fn(); + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as any); + }); + it('renders Unacknowledge when lastAckAction is undefined (treated as acknowledged)', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Acknowledge' ); @@ -23,7 +39,7 @@ describe('AcknowledgeActionButton', () => { }); it('renders Unacknowledge when lastAckAction is ack', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Unacknowledge' ); @@ -35,7 +51,7 @@ describe('AcknowledgeActionButton', () => { }); it('renders Acknowledge when lastAckAction is unack', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Acknowledge' ); @@ -45,4 +61,24 @@ describe('AcknowledgeActionButton', () => { .querySelector('[data-euiicon-type="checkCircle"]') ).toBeInTheDocument(); }); + + it('calls ack route mutation on click', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('alertEpisodeAcknowledgeActionButton')); + + expect(mutate).toHaveBeenCalledWith({ + groupHash: 'gh-1', + actionType: 'ack', + body: { episode_id: 'ep-1' }, + }); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx index 1abe695d61125..e77dfa4a0effe 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -8,13 +8,25 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; export interface AcknowledgeActionButtonProps { lastAckAction?: string | null; + episodeId?: string; + groupHash?: string | null; + http: HttpStart; } -export function AcknowledgeActionButton({ lastAckAction }: AcknowledgeActionButtonProps) { +export function AcknowledgeActionButton({ + lastAckAction, + episodeId, + groupHash, + http, +}: AcknowledgeActionButtonProps) { const isAcknowledged = lastAckAction === 'ack'; + const actionType = isAcknowledged ? 'unack' : 'ack'; + const createAlertActionMutation = useCreateAlertAction(http); const label = isAcknowledged ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', { @@ -30,7 +42,18 @@ export function AcknowledgeActionButton({ lastAckAction }: AcknowledgeActionButt color="text" fill={false} iconType={isAcknowledged ? 'crossCircle' : 'checkCircle'} - onClick={() => {}} + onClick={() => { + if (!episodeId || !groupHash) { + return; + } + createAlertActionMutation.mutate({ + groupHash, + actionType, + body: { episode_id: episodeId }, + }); + }} + isLoading={createAlertActionMutation.isLoading} + isDisabled={!episodeId || !groupHash} data-test-subj="alertEpisodeAcknowledgeActionButton" > {label} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index 0141c4c03d9d3..36aabe607028a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -9,10 +9,23 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AlertEpisodeActionsCell } from './alert_episode_actions_cell'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); +const mockServices = { http: {} as any }; describe('AlertEpisodeActionsCell', () => { + beforeEach(() => { + useCreateAlertActionMock.mockReturnValue({ + mutate: jest.fn(), + isLoading: false, + } as any); + }); + it('renders acknowledge, snooze, and more-actions controls', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toBeInTheDocument(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toBeInTheDocument(); expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument(); @@ -22,6 +35,7 @@ describe('AlertEpisodeActionsCell', () => { const user = userEvent.setup(); render( { await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Deactivate'); + ).toHaveTextContent('Unresolve'); }); it('shows Activate in popover when episode is deactivated', async () => { const user = userEvent.setup(); render( { await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Activate'); + ).toHaveTextContent('Resolve'); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx index ee847f63075cc..6d33828daeade 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiListGroup, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { HttpStart } from '@kbn/core-http-browser'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { SnoozeActionButton } from './snooze_action_button'; import type { EpisodeAction } from '../../../types/episode_action'; @@ -15,9 +16,10 @@ import { ResolveActionButton } from './deactivate_action_button'; export interface AlertEpisodeActionsCellProps { episodeAction?: EpisodeAction; + http: HttpStart; } -export function AlertEpisodeActionsCell({ episodeAction }: AlertEpisodeActionsCellProps) { +export function AlertEpisodeActionsCell({ episodeAction, http }: AlertEpisodeActionsCellProps) { const [isMoreOpen, setIsMoreOpen] = useState(false); return ( @@ -29,10 +31,19 @@ export function AlertEpisodeActionsCell({ episodeAction }: AlertEpisodeActionsCe justifyContent="flexEnd" > - + - + - + diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx index 4c4e39da0f666..efd7e1d165008 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx @@ -89,7 +89,7 @@ export const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ interface AlertEpisodeSnoozeFormProps { isSnoozed: boolean; - onApplySnooze: (snoozedUntil: string) => void; + onApplySnooze: (expiry: string) => void; onCancelSnooze: () => void; } diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx index 750d38b71e17f..549f1430ed2d8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx @@ -7,20 +7,49 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ResolveActionButton } from './deactivate_action_button'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); +const mockServices = { http: {} as any }; describe('ResolveActionButton', () => { + const mutate = jest.fn(); + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as any); + }); + it('renders Deactivate when not deactivated', () => { - render(); + render(); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( 'Unresolve' ); }); it('renders Activate when deactivated', () => { - render(); + render(); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( 'Resolve' ); }); + + it('calls deactivate route mutation on click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertingEpisodeActionsResolveActionButton')); + + expect(mutate).toHaveBeenCalledWith({ + groupHash: 'gh-1', + actionType: 'deactivate', + body: { reason: 'Updated from episodes actions UI' }, + }); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx index 3aa04cc3176dd..672e0e3068105 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx @@ -8,13 +8,23 @@ import React from 'react'; import { EuiListGroupItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; export interface ResolveActionButtonProps { lastDeactivateAction?: string | null; + groupHash?: string | null; + http: HttpStart; } -export function ResolveActionButton({ lastDeactivateAction }: ResolveActionButtonProps) { +export function ResolveActionButton({ + lastDeactivateAction, + groupHash, + http, +}: ResolveActionButtonProps) { const isDeactivated = lastDeactivateAction === 'deactivate'; + const actionType = isDeactivated ? 'activate' : 'deactivate'; + const createAlertActionMutation = useCreateAlertAction(http); const label = isDeactivated ? i18n.translate('xpack.alertingV2.episodesUi.resolveAction.activate', { @@ -31,7 +41,21 @@ export function ResolveActionButton({ lastDeactivateAction }: ResolveActionButto label={label} size="s" iconType={iconType} - onClick={() => {}} + onClick={() => { + if (!groupHash) { + return; + } + createAlertActionMutation.mutate({ + groupHash, + actionType, + body: { + reason: i18n.translate('xpack.alertingV2.episodesUi.resolveAction.reason', { + defaultMessage: 'Updated from episodes actions UI', + }), + }, + }); + }} + isDisabled={!groupHash} data-test-subj="alertingEpisodeActionsResolveActionButton" /> ); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx index ed56cd3d62286..2a511a7a5c2a2 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx @@ -9,10 +9,25 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; import { SnoozeActionButton } from './snooze_action_button'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); +const mockServices = { http: {} as any }; describe('SnoozeActionButton', () => { + const mutate = jest.fn(); + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as any); + }); + it('renders Snooze with bellSlash when not snoozed', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); expect( screen @@ -22,7 +37,7 @@ describe('SnoozeActionButton', () => { }); it('renders Unsnooze with bell when snoozed', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Unsnooze'); expect( screen @@ -33,7 +48,7 @@ describe('SnoozeActionButton', () => { it('opens popover with snooze form content on click', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); @@ -44,7 +59,7 @@ describe('SnoozeActionButton', () => { it('closes popover after clicking Apply', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); await user.click(screen.getByRole('button', { name: 'Apply' })); @@ -56,7 +71,9 @@ describe('SnoozeActionButton', () => { it('shows cancel snooze and closes popover after click when snoozed', async () => { const user = userEvent.setup(); - render(); + render( + + ); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); @@ -69,4 +86,18 @@ describe('SnoozeActionButton', () => { expect(screen.queryByTestId('alertEpisodeSnoozeForm')).not.toBeInTheDocument() ); }); + + it('calls snooze route mutation when applying from popover', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); + await user.click(screen.getByRole('button', { name: 'Apply' })); + + expect(mutate).toHaveBeenCalledWith({ + groupHash: 'gh-1', + actionType: 'snooze', + body: { expiry: expect.any(String) }, + }); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index 21091428990e1..f78a9efbc24ad 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -8,17 +8,26 @@ import React, { useState } from 'react'; import { EuiButton, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { HttpStart } from '@kbn/core-http-browser'; import { AlertEpisodeSnoozeForm } from './alert_episode_snooze_form'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; export interface SnoozeActionButtonProps { lastSnoozeAction?: string | null; + groupHash?: string | null; + http: HttpStart; } -export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps) { +export function SnoozeActionButton({ + lastSnoozeAction, + groupHash, + http, +}: SnoozeActionButtonProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const isSnoozed = lastSnoozeAction === 'snooze'; const togglePopover = () => setIsPopoverOpen((prev) => !prev); const closePopover = () => setIsPopoverOpen(false); + const createAlertActionMutation = useCreateAlertAction(http); const label = isSnoozed ? i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { @@ -41,6 +50,8 @@ export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps fill={false} iconType={isSnoozed ? 'bell' : 'bellSlash'} onClick={togglePopover} + isDisabled={!groupHash} + isLoading={createAlertActionMutation.isLoading} data-test-subj="alertEpisodeSnoozeActionButton" > {label} @@ -54,10 +65,26 @@ export function SnoozeActionButton({ lastSnoozeAction }: SnoozeActionButtonProps > { + onApplySnooze={(expiry) => { + if (!groupHash) { + return; + } + createAlertActionMutation.mutate({ + groupHash, + actionType: 'snooze', + body: { expiry }, + }); closePopover(); }} onCancelSnooze={() => { + if (!groupHash) { + return; + } + createAlertActionMutation.mutate({ + groupHash, + actionType: 'unsnooze', + body: {}, + }); closePopover(); }} /> diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts new file mode 100644 index 0000000000000..bde7421ed9aaa --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -0,0 +1,45 @@ +/* + * 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 { HttpStart } from '@kbn/core-http-browser'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { queryKeys } from '../query_keys'; + +const INTERNAL_ALERTING_V2_ALERT_API_PATH = '/internal/alerting/v2/alerts'; + +type AlertActionRouteType = + | 'ack' + | 'unack' + | 'tag' + | 'snooze' + | 'unsnooze' + | 'activate' + | 'deactivate'; + +interface CreateAlertActionParams { + groupHash: string; + actionType: AlertActionRouteType; + body?: Record; +} + +export const useCreateAlertAction = (http: HttpStart) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ groupHash, actionType, body = {} }: CreateAlertActionParams) => { + await http.post( + `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, + { + body: JSON.stringify(body), + } + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.all }); + }, + }); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index 79f1ac61a4598..7e4b46965f08d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -10,6 +10,7 @@ import { useQuery } from '@kbn/react-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { EpisodeAction } from '../types/episode_action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { queryKeys } from '../query_keys'; const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; @@ -53,7 +54,7 @@ export interface UseFetchEpisodeActionsOptions { export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisodeActionsOptions) => { const { data, isLoading } = useQuery({ - queryKey: ['fetchEpisodeActions', episodeIds], + queryKey: queryKeys.actions(episodeIds), queryFn: async ({ signal }) => { const query = buildBulkGetAlertActionsQuery(episodeIds); const result = await executeEsqlQuery({ diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts index af21c9f75d458..686e0f056bfb1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts @@ -8,4 +8,6 @@ export const queryKeys = { all: ['alert-episodes'] as const, list: (pageSize: number) => [...queryKeys.all, 'list', pageSize] as const, + actionsAll: () => [...queryKeys.all, 'actions'] as const, + actions: (episodeIds: string[]) => [...queryKeys.actionsAll(), ...episodeIds] as const, }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts index 892a793d94a9a..d1549ffb3a49e 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts @@ -24,6 +24,7 @@ const tagActionSchema = z.object({ const snoozeActionSchema = z.object({ action_type: z.literal('snooze').describe('Snoozes an alert.'), + expiry: z.string().optional().describe('ISO datetime when snooze should expire.'), }); const unsnoozeActionSchema = z.object({ @@ -40,6 +41,39 @@ const deactivateActionSchema = z.object({ reason: z.string().describe('Reason for deactivating the alert.'), }); +export const createAckAlertActionBodySchema = ackActionSchema.omit({ action_type: true }).strict(); +export type CreateAckAlertActionBody = z.infer; + +export const createUnackAlertActionBodySchema = unackActionSchema + .omit({ action_type: true }) + .strict(); +export type CreateUnackAlertActionBody = z.infer; + +export const createTagAlertActionBodySchema = tagActionSchema.omit({ action_type: true }).strict(); +export type CreateTagAlertActionBody = z.infer; + +export const createSnoozeAlertActionBodySchema = snoozeActionSchema + .omit({ action_type: true }) + .strict(); +export type CreateSnoozeAlertActionBody = z.infer; + +export const createUnsnoozeAlertActionBodySchema = unsnoozeActionSchema + .omit({ action_type: true }) + .strict(); +export type CreateUnsnoozeAlertActionBody = z.infer; + +export const createActivateAlertActionBodySchema = activateActionSchema + .omit({ action_type: true }) + .strict(); +export type CreateActivateAlertActionBody = z.infer; + +export const createDeactivateAlertActionBodySchema = deactivateActionSchema + .omit({ + action_type: true, + }) + .strict(); +export type CreateDeactivateAlertActionBody = z.infer; + export const createAlertActionBodySchema = z .discriminatedUnion('action_type', [ ackActionSchema, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts new file mode 100644 index 0000000000000..598ebc424961b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { createAckAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateAckAlertActionRoute } from './create_ack_alert_action_route'; + +describe('CreateAckAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateAckAlertActionRoute.path.endsWith('/_ack')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = { episode_id: 'ep-1' }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateAckAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'ack', ...body }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({ episode_id: 'ep-1' }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateAckAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createAckAlertActionBodySchema', () => { + it('accepts payload without action_type', () => { + expect(createAckAlertActionBodySchema.safeParse({ episode_id: 'ep-1' }).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect( + createAckAlertActionBodySchema.safeParse({ action_type: 'ack', episode_id: 'ep-1' }).success + ).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts new file mode 100644 index 0000000000000..747f277c3afeb --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createAckAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateAckAlertActionRoute = createAlertActionRouteForType({ + actionType: 'ack', + pathSuffix: '_ack', + bodySchema: createAckAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts new file mode 100644 index 0000000000000..c54a305d9705c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { createActivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateActivateAlertActionRoute } from './create_activate_alert_action_route'; + +describe('CreateActivateAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateActivateAlertActionRoute.path.endsWith('/_activate')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = { reason: 'manual override' }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateActivateAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'activate', ...body }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({ reason: 'x' }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateActivateAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createActivateAlertActionBodySchema', () => { + it('accepts payload without action_type', () => { + expect(createActivateAlertActionBodySchema.safeParse({ reason: 'x' }).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect( + createActivateAlertActionBodySchema.safeParse({ action_type: 'activate', reason: 'x' }) + .success + ).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts new file mode 100644 index 0000000000000..6667657c4c301 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createActivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateActivateAlertActionRoute = createAlertActionRouteForType({ + actionType: 'activate', + pathSuffix: '_activate', + bodySchema: createActivateAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts new file mode 100644 index 0000000000000..00b358b2ea338 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { createTagAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +describe('createAlertActionRouteForType', () => { + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const buildDeps = (body: Record) => { + const request = { + params: { group_hash: 'group-1' }, + body, + }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { + createAction: jest.fn(), + }; + return { request, response, alertActionsClient }; + }; + + it('creates a route class with expected static metadata', () => { + const RouteClass = createAlertActionRouteForType({ + actionType: 'tag', + pathSuffix: '_tag', + bodySchema: createTagAlertActionBodySchema, + }); + + expect(RouteClass.method).toBe('post'); + expect(RouteClass.path).toBe('/internal/alerting/v2/alerts/{group_hash}/action/_tag'); + expect(RouteClass.validate).toBeDefined(); + }); + + it('injects inferred action_type into createAction payload', async () => { + const RouteClass = createAlertActionRouteForType({ + actionType: 'tag', + pathSuffix: '_tag', + bodySchema: createTagAlertActionBodySchema, + }); + const { request, response, alertActionsClient } = buildDeps({ tags: ['p1'] }); + const route = new RouteClass(request as any, response as any, alertActionsClient as any); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash: 'group-1', + action: { + action_type: 'tag', + tags: ['p1'], + }, + }); + expect(result).toBe(noContentResult); + }); + + it('maps thrown error to customError response', async () => { + const RouteClass = createAlertActionRouteForType({ + actionType: 'tag', + pathSuffix: '_tag', + bodySchema: createTagAlertActionBodySchema, + }); + const { request, response, alertActionsClient } = buildDeps({ tags: ['p1'] }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new RouteClass(request as any, response as any, alertActionsClient as any); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); + + it('applies body mapper when provided', async () => { + const RouteClass = createAlertActionRouteForType({ + actionType: 'tag', + pathSuffix: '_tag', + bodySchema: createTagAlertActionBodySchema, + mapBody: (body) => ({ ...body, tags: ['mapped'] }), + }); + const { request, response, alertActionsClient } = buildDeps({ tags: ['p1'] }); + const route = new RouteClass(request as any, response as any, alertActionsClient as any); + + await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash: 'group-1', + action: { + action_type: 'tag', + tags: ['mapped'], + }, + }); + expect(response.noContent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts new file mode 100644 index 0000000000000..1b516cf9846f7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts @@ -0,0 +1,93 @@ +/* + * 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 Boom from '@hapi/boom'; +import { + createAlertActionParamsSchema, + type CreateAlertActionBody, + type CreateAlertActionParams, +} from '@kbn/alerting-v2-schemas'; +import { Request, Response, type RouteHandler } from '@kbn/core-di-server'; +import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; +import { inject, injectable } from 'inversify'; +import type { z } from '@kbn/zod/v4'; +import { AlertActionsClient } from '../../lib/alert_actions_client'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { INTERNAL_ALERTING_V2_ALERT_API_PATH } from '../constants'; + +interface CreateAlertActionRouteForTypeOptions< + TAction extends CreateAlertActionBody['action_type'] +> { + actionType: TAction; + pathSuffix: string; + bodySchema: z.ZodType< + Omit, 'action_type'> + >; + mapBody?: ( + body: Omit, 'action_type'> + ) => Omit, 'action_type'>; +} + +export const createAlertActionRouteForType = < + TAction extends CreateAlertActionBody['action_type'] +>({ + actionType, + pathSuffix, + bodySchema, + mapBody, +}: CreateAlertActionRouteForTypeOptions) => { + type ActionBody = Omit, 'action_type'>; + + @injectable() + class CreateTypedAlertActionRoute implements RouteHandler { + static method = 'post' as const; + static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/{group_hash}/action/${pathSuffix}`; + static security: RouteSecurity = { + authz: { + requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.write], + }, + }; + static options = { access: 'internal' } as const; + static validate = { + request: { + params: buildRouteValidationWithZod(createAlertActionParamsSchema), + body: buildRouteValidationWithZod(bodySchema), + }, + } as const; + + constructor( + @inject(Request) + private readonly request: KibanaRequest, + @inject(Response) private readonly response: KibanaResponseFactory, + @inject(AlertActionsClient) private readonly alertActionsClient: AlertActionsClient + ) {} + + async handle() { + try { + const mappedBody = mapBody ? mapBody(this.request.body) : this.request.body; + await this.alertActionsClient.createAction({ + groupHash: this.request.params.group_hash, + action: { + action_type: actionType, + ...mappedBody, + } as Extract, + }); + + return this.response.noContent(); + } catch (e) { + const boom = Boom.isBoom(e) ? e : Boom.boomify(e); + return this.response.customError({ + statusCode: boom.output.statusCode, + body: boom.output.payload, + }); + } + } + } + + return CreateTypedAlertActionRoute; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts new file mode 100644 index 0000000000000..fb3fadaca269d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { createDeactivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateDeactivateAlertActionRoute } from './create_deactivate_alert_action_route'; + +describe('CreateDeactivateAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateDeactivateAlertActionRoute.path.endsWith('/_deactivate')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = { reason: 'manual pause' }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateDeactivateAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'deactivate', ...body }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({ reason: 'x' }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateDeactivateAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createDeactivateAlertActionBodySchema', () => { + it('accepts payload without action_type', () => { + expect(createDeactivateAlertActionBodySchema.safeParse({ reason: 'x' }).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect( + createDeactivateAlertActionBodySchema.safeParse({ action_type: 'deactivate', reason: 'x' }) + .success + ).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts new file mode 100644 index 0000000000000..3d45738dc17bf --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createDeactivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateDeactivateAlertActionRoute = createAlertActionRouteForType({ + actionType: 'deactivate', + pathSuffix: '_deactivate', + bodySchema: createDeactivateAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts new file mode 100644 index 0000000000000..7c43071567612 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { createSnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateSnoozeAlertActionRoute } from './create_snooze_alert_action_route'; + +describe('CreateSnoozeAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateSnoozeAlertActionRoute.path.endsWith('/_snooze')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = {}; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateSnoozeAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'snooze' }, + }); + expect(result).toBe(noContentResult); + }); + + it('passes expiry through for snooze action payload', async () => { + const body = { expiry: '2026-01-28T16:03:00.000Z' }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateSnoozeAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { + action_type: 'snooze', + expiry: '2026-01-28T16:03:00.000Z', + }, + }); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({}); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateSnoozeAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createSnoozeAlertActionBodySchema', () => { + it('accepts empty payload without action_type', () => { + expect(createSnoozeAlertActionBodySchema.safeParse({}).success).toBe(true); + }); + + it('accepts payload with expiry', () => { + expect( + createSnoozeAlertActionBodySchema.safeParse({ expiry: '2026-01-28T16:03:00.000Z' }).success + ).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect(createSnoozeAlertActionBodySchema.safeParse({ action_type: 'snooze' }).success).toBe( + false + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts new file mode 100644 index 0000000000000..47d71fc8158ec --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createSnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateSnoozeAlertActionRoute = createAlertActionRouteForType({ + actionType: 'snooze', + pathSuffix: '_snooze', + bodySchema: createSnoozeAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts new file mode 100644 index 0000000000000..a604987c408b0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { createTagAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateTagAlertActionRoute } from './create_tag_alert_action_route'; + +describe('CreateTagAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateTagAlertActionRoute.path.endsWith('/_tag')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = { tags: ['p1', 'p2'] }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateTagAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'tag', ...body }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({ tags: ['x'] }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateTagAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createTagAlertActionBodySchema', () => { + it('accepts payload without action_type', () => { + expect(createTagAlertActionBodySchema.safeParse({ tags: ['foo'] }).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect( + createTagAlertActionBodySchema.safeParse({ action_type: 'tag', tags: ['foo'] }).success + ).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts new file mode 100644 index 0000000000000..651636e4834d7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createTagAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateTagAlertActionRoute = createAlertActionRouteForType({ + actionType: 'tag', + pathSuffix: '_tag', + bodySchema: createTagAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts new file mode 100644 index 0000000000000..2f0951485f70f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { createUnackAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateUnackAlertActionRoute } from './create_unack_alert_action_route'; + +describe('CreateUnackAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateUnackAlertActionRoute.path.endsWith('/_unack')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = { episode_id: 'ep-1' }; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateUnackAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'unack', ...body }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({ episode_id: 'ep-1' }); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateUnackAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createUnackAlertActionBodySchema', () => { + it('accepts payload without action_type', () => { + expect(createUnackAlertActionBodySchema.safeParse({ episode_id: 'ep-1' }).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect( + createUnackAlertActionBodySchema.safeParse({ action_type: 'unack', episode_id: 'ep-1' }) + .success + ).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts new file mode 100644 index 0000000000000..9f1902219c7d4 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createUnackAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateUnackAlertActionRoute = createAlertActionRouteForType({ + actionType: 'unack', + pathSuffix: '_unack', + bodySchema: createUnackAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts new file mode 100644 index 0000000000000..4f0e3041304f2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { createUnsnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { CreateUnsnoozeAlertActionRoute } from './create_unsnooze_alert_action_route'; + +describe('CreateUnsnoozeAlertActionRoute', () => { + const groupHash = 'group-1'; + const noContentResult = { noContent: true }; + const customErrorResult = { customError: true }; + + const createDeps = (body: Record) => { + const request = { params: { group_hash: groupHash }, body }; + const response = { + noContent: jest.fn().mockReturnValue(noContentResult), + customError: jest.fn().mockReturnValue(customErrorResult), + }; + const alertActionsClient = { createAction: jest.fn() }; + return { request, response, alertActionsClient }; + }; + + it('has expected path suffix', () => { + expect(CreateUnsnoozeAlertActionRoute.path.endsWith('/_unsnooze')).toBe(true); + }); + + it('injects action_type and returns noContent', async () => { + const body = {}; + const { request, response, alertActionsClient } = createDeps(body); + const route = new CreateUnsnoozeAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(alertActionsClient.createAction).toHaveBeenCalledWith({ + groupHash, + action: { action_type: 'unsnooze' }, + }); + expect(result).toBe(noContentResult); + }); + + it('returns customError on failure', async () => { + const { request, response, alertActionsClient } = createDeps({}); + alertActionsClient.createAction.mockRejectedValueOnce(new Error('boom')); + const route = new CreateUnsnoozeAlertActionRoute( + request as any, + response as any, + alertActionsClient as any + ); + + const result = await route.handle(); + + expect(response.customError).toHaveBeenCalledTimes(1); + expect(result).toBe(customErrorResult); + }); +}); + +describe('createUnsnoozeAlertActionBodySchema', () => { + it('accepts empty payload without action_type', () => { + expect(createUnsnoozeAlertActionBodySchema.safeParse({}).success).toBe(true); + }); + + it('rejects payload with action_type', () => { + expect(createUnsnoozeAlertActionBodySchema.safeParse({ action_type: 'unsnooze' }).success).toBe( + false + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts new file mode 100644 index 0000000000000..6548b6fc5f1b6 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { createUnsnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateUnsnoozeAlertActionRoute = createAlertActionRouteForType({ + actionType: 'unsnooze', + pathSuffix: '_unsnooze', + bodySchema: createUnsnoozeAlertActionBodySchema, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index a05f6518f3066..62ae76c24c468 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -18,6 +18,13 @@ import { BulkEnableRulesRoute } from '../routes/rules/bulk_enable_rules_route'; import { BulkDisableRulesRoute } from '../routes/rules/bulk_disable_rules_route'; import { CreateAlertActionRoute } from '../routes/alert_actions/create_alert_action_route'; import { BulkCreateAlertActionRoute } from '../routes/alert_actions/bulk_create_alert_action_route'; +import { CreateAckAlertActionRoute } from '../routes/alert_actions/create_ack_alert_action_route'; +import { CreateUnackAlertActionRoute } from '../routes/alert_actions/create_unack_alert_action_route'; +import { CreateTagAlertActionRoute } from '../routes/alert_actions/create_tag_alert_action_route'; +import { CreateSnoozeAlertActionRoute } from '../routes/alert_actions/create_snooze_alert_action_route'; +import { CreateUnsnoozeAlertActionRoute } from '../routes/alert_actions/create_unsnooze_alert_action_route'; +import { CreateActivateAlertActionRoute } from '../routes/alert_actions/create_activate_alert_action_route'; +import { CreateDeactivateAlertActionRoute } from '../routes/alert_actions/create_deactivate_alert_action_route'; import { BulkActionNotificationPoliciesRoute } from '../routes/notification_policies/bulk_action_notification_policies_route'; import { CreateNotificationPolicyRoute } from '../routes/notification_policies/create_notification_policy_route'; import { DisableNotificationPolicyRoute } from '../routes/notification_policies/disable_notification_policy_route'; @@ -41,6 +48,13 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(BulkEnableRulesRoute); bind(Route).toConstantValue(BulkDisableRulesRoute); bind(Route).toConstantValue(CreateAlertActionRoute); + bind(Route).toConstantValue(CreateAckAlertActionRoute); + bind(Route).toConstantValue(CreateUnackAlertActionRoute); + bind(Route).toConstantValue(CreateTagAlertActionRoute); + bind(Route).toConstantValue(CreateSnoozeAlertActionRoute); + bind(Route).toConstantValue(CreateUnsnoozeAlertActionRoute); + bind(Route).toConstantValue(CreateActivateAlertActionRoute); + bind(Route).toConstantValue(CreateDeactivateAlertActionRoute); bind(Route).toConstantValue(BulkCreateAlertActionRoute); bind(Route).toConstantValue(CreateNotificationPolicyRoute); bind(Route).toConstantValue(GetNotificationPolicyRoute); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index bbb01afc4ef11..4df63728d7b43 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -220,7 +220,7 @@ export function AlertsV2Page() { actions: (props) => { const episodeId = props.row.flattened['episode.id'] as string; const episodeAction = actionsMap.get(episodeId); - return ; + return ; }, tags: (props) => { const episodeId = props.row.flattened['episode.id'] as string; From fefa77e943d52ec51b32b2114d9fbf2c51aff01e Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 11:05:27 +0200 Subject: [PATCH 09/28] Use action type constant in the episodes-ui. --- .../acknowledge_action_button.test.tsx | 19 +++++++++--- .../actions/acknowledge_action_button.tsx | 7 +++-- .../alert_episode_actions_cell.test.tsx | 3 +- .../actions/deactivate_action_button.test.tsx | 14 +++++++-- .../actions/deactivate_action_button.tsx | 7 +++-- .../actions/snooze_action_button.test.tsx | 28 +++++++++++++---- .../actions/snooze_action_button.tsx | 13 +++----- .../hooks/use_create_alert_action.ts | 21 +++---------- .../alerting-v2-episodes-ui/tsconfig.json | 1 + .../src/alert_action_schema.ts | 31 ++++++++++++++----- 10 files changed, 95 insertions(+), 49 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx index 0a6ef90a8fc23..07500f482c171 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -39,7 +40,12 @@ describe('AcknowledgeActionButton', () => { }); it('renders Unacknowledge when lastAckAction is ack', () => { - render(); + render( + + ); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Unacknowledge' ); @@ -51,7 +57,12 @@ describe('AcknowledgeActionButton', () => { }); it('renders Acknowledge when lastAckAction is unack', () => { - render(); + render( + + ); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Acknowledge' ); @@ -66,7 +77,7 @@ describe('AcknowledgeActionButton', () => { const user = userEvent.setup(); render( { expect(mutate).toHaveBeenCalledWith({ groupHash: 'gh-1', - actionType: 'ack', + actionType: ALERT_EPISODE_ACTION_TYPE.ACK, body: { episode_id: 'ep-1' }, }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx index e77dfa4a0effe..54c0a2d7c64e3 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; export interface AcknowledgeActionButtonProps { @@ -24,8 +25,10 @@ export function AcknowledgeActionButton({ groupHash, http, }: AcknowledgeActionButtonProps) { - const isAcknowledged = lastAckAction === 'ack'; - const actionType = isAcknowledged ? 'unack' : 'ack'; + const isAcknowledged = lastAckAction === ALERT_EPISODE_ACTION_TYPE.ACK; + const actionType = isAcknowledged + ? ALERT_EPISODE_ACTION_TYPE.UNACK + : ALERT_EPISODE_ACTION_TYPE.ACK; const createAlertActionMutation = useCreateAlertAction(http); const label = isAcknowledged diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index 36aabe607028a..19f133c243233 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AlertEpisodeActionsCell } from './alert_episode_actions_cell'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -63,7 +64,7 @@ describe('AlertEpisodeActionsCell', () => { ruleId: 'r1', groupHash: 'g1', lastAckAction: null, - lastDeactivateAction: 'deactivate', + lastDeactivateAction: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, lastSnoozeAction: null, tags: [], }} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx index 549f1430ed2d8..8451f4537f6d7 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { ResolveActionButton } from './deactivate_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -34,7 +35,12 @@ describe('ResolveActionButton', () => { }); it('renders Activate when deactivated', () => { - render(); + render( + + ); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( 'Resolve' ); @@ -42,13 +48,15 @@ describe('ResolveActionButton', () => { it('calls deactivate route mutation on click', async () => { const user = userEvent.setup(); - render(); + render( + + ); await user.click(screen.getByTestId('alertingEpisodeActionsResolveActionButton')); expect(mutate).toHaveBeenCalledWith({ groupHash: 'gh-1', - actionType: 'deactivate', + actionType: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, body: { reason: 'Updated from episodes actions UI' }, }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx index 672e0e3068105..e4c582ee329c8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EuiListGroupItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; export interface ResolveActionButtonProps { @@ -22,8 +23,10 @@ export function ResolveActionButton({ groupHash, http, }: ResolveActionButtonProps) { - const isDeactivated = lastDeactivateAction === 'deactivate'; - const actionType = isDeactivated ? 'activate' : 'deactivate'; + const isDeactivated = lastDeactivateAction === ALERT_EPISODE_ACTION_TYPE.DEACTIVATE; + const actionType = isDeactivated + ? ALERT_EPISODE_ACTION_TYPE.ACTIVATE + : ALERT_EPISODE_ACTION_TYPE.DEACTIVATE; const createAlertActionMutation = useCreateAlertAction(http); const label = isDeactivated diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx index 2a511a7a5c2a2..736c475b95b0e 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { SnoozeActionButton } from './snooze_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -37,7 +38,12 @@ describe('SnoozeActionButton', () => { }); it('renders Unsnooze with bell when snoozed', () => { - render(); + render( + + ); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Unsnooze'); expect( screen @@ -48,7 +54,9 @@ describe('SnoozeActionButton', () => { it('opens popover with snooze form content on click', async () => { const user = userEvent.setup(); - render(); + render( + + ); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); @@ -59,7 +67,9 @@ describe('SnoozeActionButton', () => { it('closes popover after clicking Apply', async () => { const user = userEvent.setup(); - render(); + render( + + ); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); await user.click(screen.getByRole('button', { name: 'Apply' })); @@ -72,7 +82,11 @@ describe('SnoozeActionButton', () => { it('shows cancel snooze and closes popover after click when snoozed', async () => { const user = userEvent.setup(); render( - + ); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); @@ -89,14 +103,16 @@ describe('SnoozeActionButton', () => { it('calls snooze route mutation when applying from popover', async () => { const user = userEvent.setup(); - render(); + render( + + ); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); await user.click(screen.getByRole('button', { name: 'Apply' })); expect(mutate).toHaveBeenCalledWith({ groupHash: 'gh-1', - actionType: 'snooze', + actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, body: { expiry: expect.any(String) }, }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index f78a9efbc24ad..21c923936c84d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -9,6 +9,7 @@ import React, { useState } from 'react'; import { EuiButton, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AlertEpisodeSnoozeForm } from './alert_episode_snooze_form'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -18,13 +19,9 @@ export interface SnoozeActionButtonProps { http: HttpStart; } -export function SnoozeActionButton({ - lastSnoozeAction, - groupHash, - http, -}: SnoozeActionButtonProps) { +export function SnoozeActionButton({ lastSnoozeAction, groupHash, http }: SnoozeActionButtonProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const isSnoozed = lastSnoozeAction === 'snooze'; + const isSnoozed = lastSnoozeAction === ALERT_EPISODE_ACTION_TYPE.SNOOZE; const togglePopover = () => setIsPopoverOpen((prev) => !prev); const closePopover = () => setIsPopoverOpen(false); const createAlertActionMutation = useCreateAlertAction(http); @@ -71,7 +68,7 @@ export function SnoozeActionButton({ } createAlertActionMutation.mutate({ groupHash, - actionType: 'snooze', + actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, body: { expiry }, }); closePopover(); @@ -82,7 +79,7 @@ export function SnoozeActionButton({ } createAlertActionMutation.mutate({ groupHash, - actionType: 'unsnooze', + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, body: {}, }); closePopover(); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts index bde7421ed9aaa..d33716cb3cdb3 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -7,22 +7,14 @@ import type { HttpStart } from '@kbn/core-http-browser'; import { useMutation, useQueryClient } from '@kbn/react-query'; +import type { AlertEpisodeActionType } from '@kbn/alerting-v2-schemas'; import { queryKeys } from '../query_keys'; const INTERNAL_ALERTING_V2_ALERT_API_PATH = '/internal/alerting/v2/alerts'; -type AlertActionRouteType = - | 'ack' - | 'unack' - | 'tag' - | 'snooze' - | 'unsnooze' - | 'activate' - | 'deactivate'; - interface CreateAlertActionParams { groupHash: string; - actionType: AlertActionRouteType; + actionType: AlertEpisodeActionType; body?: Record; } @@ -31,12 +23,9 @@ export const useCreateAlertAction = (http: HttpStart) => { return useMutation({ mutationFn: async ({ groupHash, actionType, body = {} }: CreateAlertActionParams) => { - await http.post( - `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, - { - body: JSON.stringify(body), - } - ); + await http.post(`${INTERNAL_ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, { + body: JSON.stringify(body), + }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.all }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json index ee5bf7cf26fce..3a3a86c10efb7 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json @@ -16,6 +16,7 @@ "target/**/*" ], "kbn_references": [ + "@kbn/alerting-v2-schemas", "@kbn/core-http-browser", "@kbn/data-views-plugin", "@kbn/field-formats-plugin", diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts index d1549ffb3a49e..5fa5569b6f48f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts @@ -7,37 +7,54 @@ import { z } from '@kbn/zod/v4'; +export const ALERT_EPISODE_ACTION_TYPE = { + ACK: 'ack', + UNACK: 'unack', + TAG: 'tag', + SNOOZE: 'snooze', + UNSNOOZE: 'unsnooze', + ACTIVATE: 'activate', + DEACTIVATE: 'deactivate', +} as const; + +export type AlertEpisodeActionType = + (typeof ALERT_EPISODE_ACTION_TYPE)[keyof typeof ALERT_EPISODE_ACTION_TYPE]; + const ackActionSchema = z.object({ - action_type: z.literal('ack').describe('Acknowledges an alert.'), + action_type: z.literal(ALERT_EPISODE_ACTION_TYPE.ACK).describe('Acknowledges an alert.'), episode_id: z.string().describe('The episode identifier for the alert to acknowledge.'), }); const unackActionSchema = z.object({ - action_type: z.literal('unack').describe('Removes acknowledgement from an alert.'), + action_type: z + .literal(ALERT_EPISODE_ACTION_TYPE.UNACK) + .describe('Removes acknowledgement from an alert.'), episode_id: z.string().describe('The episode identifier for the alert to unacknowledge.'), }); const tagActionSchema = z.object({ - action_type: z.literal('tag').describe('Adds tags to an alert.'), + action_type: z.literal(ALERT_EPISODE_ACTION_TYPE.TAG).describe('Adds tags to an alert.'), tags: z.array(z.string()).describe('List of tags to add to the alert.'), }); const snoozeActionSchema = z.object({ - action_type: z.literal('snooze').describe('Snoozes an alert.'), + action_type: z.literal(ALERT_EPISODE_ACTION_TYPE.SNOOZE).describe('Snoozes an alert.'), expiry: z.string().optional().describe('ISO datetime when snooze should expire.'), }); const unsnoozeActionSchema = z.object({ - action_type: z.literal('unsnooze').describe('Removes snooze from an alert.'), + action_type: z + .literal(ALERT_EPISODE_ACTION_TYPE.UNSNOOZE) + .describe('Removes snooze from an alert.'), }); const activateActionSchema = z.object({ - action_type: z.literal('activate').describe('Activates an alert.'), + action_type: z.literal(ALERT_EPISODE_ACTION_TYPE.ACTIVATE).describe('Activates an alert.'), reason: z.string().describe('Reason for activating the alert.'), }); const deactivateActionSchema = z.object({ - action_type: z.literal('deactivate').describe('Deactivates an alert.'), + action_type: z.literal(ALERT_EPISODE_ACTION_TYPE.DEACTIVATE).describe('Deactivates an alert.'), reason: z.string().describe('Reason for deactivating the alert.'), }); From 3c4a7b12a27bde63132f414223ed8c3d977d8343 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:18:58 +0000 Subject: [PATCH 10/28] Changes from node scripts/lint_ts_projects --fix --- .../shared/response-ops/alerting-v2-episodes-ui/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json index 3a3a86c10efb7..42d91116e0e60 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/tsconfig.json @@ -27,6 +27,7 @@ "@kbn/core-http-browser-mocks", "@kbn/react-query", "@kbn/data-plugin", - "@kbn/discover-utils" + "@kbn/discover-utils", + "@kbn/i18n-react" ] } From f061998cac3af1b02d5c4309757bebf7ae60c7a8 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:32:31 +0000 Subject: [PATCH 11/28] Changes from node scripts/regenerate_moon_projects.js --update --- .../shared/response-ops/alerting-v2-episodes-ui/moon.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml index 4969e22da1573..db1477566ec0f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml @@ -17,6 +17,7 @@ project: owner: '@elastic/response-ops' sourceRoot: x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui dependsOn: + - '@kbn/alerting-v2-schemas' - '@kbn/core-http-browser' - '@kbn/data-views-plugin' - '@kbn/field-formats-plugin' @@ -28,6 +29,7 @@ dependsOn: - '@kbn/react-query' - '@kbn/data-plugin' - '@kbn/discover-utils' + - '@kbn/i18n-react' tags: - shared-browser - package From 25cc2da3926e3299d300d59ee4e39fae3f212582 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 11:41:36 +0200 Subject: [PATCH 12/28] Add small PR fixes. --- .../actions/acknowledge_action_button.tsx | 6 +- .../alert_episode_actions_cell.test.tsx | 4 +- .../alert_episode_snooze_form.test.tsx | 21 +++---- .../actions/alert_episode_snooze_form.tsx | 57 +++++++++---------- .../actions/alert_episode_tags.test.tsx | 3 +- .../actions/deactivate_action_button.test.tsx | 4 +- .../actions/deactivate_action_button.tsx | 4 +- .../actions/snooze_action_button.test.tsx | 21 ++++++- .../actions/snooze_action_button.tsx | 18 +++--- .../status/alert_episode_status_cell.test.tsx | 23 +++++++- .../status/alert_episode_status_cell.tsx | 21 ++++--- .../hooks/use_fetch_episode_actions.test.tsx | 2 +- .../create_ack_alert_action_route.test.ts | 4 -- ...create_activate_alert_action_route.test.ts | 4 -- ...create_alert_action_route_for_type.test.ts | 5 +- ...eate_deactivate_alert_action_route.test.ts | 4 -- .../create_snooze_alert_action_route.test.ts | 4 -- .../create_tag_alert_action_route.test.ts | 4 -- .../create_unack_alert_action_route.test.ts | 4 -- ...create_unsnooze_alert_action_route.test.ts | 4 -- 20 files changed, 110 insertions(+), 107 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx index 54c0a2d7c64e3..6d46c55685b94 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -29,7 +29,7 @@ export function AcknowledgeActionButton({ const actionType = isAcknowledged ? ALERT_EPISODE_ACTION_TYPE.UNACK : ALERT_EPISODE_ACTION_TYPE.ACK; - const createAlertActionMutation = useCreateAlertAction(http); + const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http); const label = isAcknowledged ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', { @@ -49,13 +49,13 @@ export function AcknowledgeActionButton({ if (!episodeId || !groupHash) { return; } - createAlertActionMutation.mutate({ + createAlertAction({ groupHash, actionType, body: { episode_id: episodeId }, }); }} - isLoading={createAlertActionMutation.isLoading} + isLoading={isLoading} isDisabled={!episodeId || !groupHash} data-test-subj="alertEpisodeAcknowledgeActionButton" > diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index 19f133c243233..c94a9d14cfdfb 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -32,7 +32,7 @@ describe('AlertEpisodeActionsCell', () => { expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument(); }); - it('opens popover and shows Deactivate from episode action state', async () => { + it('opens popover and shows Unresolve from episode action state', async () => { const user = userEvent.setup(); render( { ).toHaveTextContent('Unresolve'); }); - it('shows Activate in popover when episode is deactivated', async () => { + it('shows Resolve in popover when episode is deactivated', async () => { const user = userEvent.setup(); render( { expect(Number.isNaN(Date.parse(onApplySnooze.mock.calls[0][0]))).toBe(false); }); - it('shows cancel button only when isSnoozed is true', () => { - const { rerender } = render( - - ); - expect(screen.queryByRole('button', { name: 'Cancel snooze' })).not.toBeInTheDocument(); + it('shows cancel button only when isSnoozed is true', async () => { + const user = userEvent.setup(); + const onCancelSnooze = jest.fn(); - rerender( + render( ); - expect(screen.getByRole('button', { name: 'Cancel snooze' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel snooze' })); + + expect(onCancelSnooze).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx index efd7e1d165008..3e2e080c7161a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx @@ -20,7 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -export type AlertEpisodeDurationUnit = 'm' | 'h' | 'd'; +type AlertEpisodeDurationUnit = 'm' | 'h' | 'd'; export const computeEpisodeSnoozedUntil = ( value: number, @@ -30,29 +30,28 @@ export const computeEpisodeSnoozedUntil = ( return new Date(Date.now() + value * ms[unit]).toISOString(); }; -export const ALERT_EPISODE_UNIT_OPTIONS: Array<{ value: AlertEpisodeDurationUnit; text: string }> = - [ - { - value: 'm', - text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.minutes', { - defaultMessage: 'Minutes', - }), - }, - { - value: 'h', - text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.hours', { - defaultMessage: 'Hours', - }), - }, - { - value: 'd', - text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.days', { - defaultMessage: 'Days', - }), - }, - ]; +const ALERT_EPISODE_UNIT_OPTIONS: Array<{ value: AlertEpisodeDurationUnit; text: string }> = [ + { + value: 'm', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.minutes', { + defaultMessage: 'Minutes', + }), + }, + { + value: 'h', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.hours', { + defaultMessage: 'Hours', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.days', { + defaultMessage: 'Days', + }), + }, +]; -export const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ +const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ label: string; value: number; unit: AlertEpisodeDurationUnit; @@ -87,17 +86,15 @@ export const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ }, ]; -interface AlertEpisodeSnoozeFormProps { - isSnoozed: boolean; - onApplySnooze: (expiry: string) => void; - onCancelSnooze: () => void; -} - export const AlertEpisodeSnoozeForm = ({ isSnoozed, onApplySnooze, onCancelSnooze, -}: AlertEpisodeSnoozeFormProps) => { +}: { + isSnoozed: boolean; + onApplySnooze: (expiry: string) => void; + onCancelSnooze: () => void; +}) => { const [durationValue, setDurationValue] = useState(1); const [durationUnit, setDurationUnit] = useState('h'); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx index c5872f45b2fbb..93018f7540012 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.test.tsx @@ -36,6 +36,7 @@ describe('AlertEpisodeTags', () => { it('opens popover with remaining tags when overflow badge is clicked', async () => { const user = userEvent.setup(); + render( @@ -43,6 +44,6 @@ describe('AlertEpisodeTags', () => { ); await user.click(screen.getByText('+2')); expect(await screen.findByText('c')).toBeInTheDocument(); - expect(screen.getByText('d')).toBeInTheDocument(); + expect(await screen.findByText('d')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx index 8451f4537f6d7..84b8508f9a6db 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx @@ -27,14 +27,14 @@ describe('ResolveActionButton', () => { } as any); }); - it('renders Deactivate when not deactivated', () => { + it('renders Unresolve when not deactivated', () => { render(); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( 'Unresolve' ); }); - it('renders Activate when deactivated', () => { + it('renders Resolve when deactivated', () => { render( { const mutate = jest.fn(); + beforeEach(() => { mutate.mockReset(); useCreateAlertActionMock.mockReturnValue({ @@ -28,7 +29,22 @@ describe('SnoozeActionButton', () => { }); it('renders Snooze with bellSlash when not snoozed', () => { - render(); + render( + + ); + expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); + expect( + screen + .getByTestId('alertEpisodeSnoozeActionButton') + .querySelector('[data-euiicon-type="bellSlash"]') + ).toBeInTheDocument(); + }); + + it('renders Snooze with bellSlash when previous action is undefined', () => { + render(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); expect( screen @@ -60,7 +76,7 @@ describe('SnoozeActionButton', () => { await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); - expect(screen.getByTestId('alertEpisodeSnoozeForm')).toBeInTheDocument(); + expect(await screen.findByTestId('alertEpisodeSnoozeForm')).toBeInTheDocument(); expect(screen.getByLabelText('Snooze duration value')).toBeInTheDocument(); expect(screen.getByLabelText('Snooze duration unit')).toBeInTheDocument(); }); @@ -92,7 +108,6 @@ describe('SnoozeActionButton', () => { await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); const cancelButton = screen.getByRole('button', { name: 'Cancel snooze' }); - expect(cancelButton).toBeInTheDocument(); await user.click(cancelButton); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index 21c923936c84d..64da6a1686173 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -13,18 +13,20 @@ import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AlertEpisodeSnoozeForm } from './alert_episode_snooze_form'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; -export interface SnoozeActionButtonProps { +export function SnoozeActionButton({ + lastSnoozeAction, + groupHash, + http, +}: { lastSnoozeAction?: string | null; groupHash?: string | null; http: HttpStart; -} - -export function SnoozeActionButton({ lastSnoozeAction, groupHash, http }: SnoozeActionButtonProps) { +}) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const isSnoozed = lastSnoozeAction === ALERT_EPISODE_ACTION_TYPE.SNOOZE; const togglePopover = () => setIsPopoverOpen((prev) => !prev); const closePopover = () => setIsPopoverOpen(false); - const createAlertActionMutation = useCreateAlertAction(http); + const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http); const label = isSnoozed ? i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { @@ -48,7 +50,7 @@ export function SnoozeActionButton({ lastSnoozeAction, groupHash, http }: Snooze iconType={isSnoozed ? 'bell' : 'bellSlash'} onClick={togglePopover} isDisabled={!groupHash} - isLoading={createAlertActionMutation.isLoading} + isLoading={isLoading} data-test-subj="alertEpisodeSnoozeActionButton" > {label} @@ -66,7 +68,7 @@ export function SnoozeActionButton({ lastSnoozeAction, groupHash, http }: Snooze if (!groupHash) { return; } - createAlertActionMutation.mutate({ + createAlertAction({ groupHash, actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, body: { expiry }, @@ -77,7 +79,7 @@ export function SnoozeActionButton({ lastSnoozeAction, groupHash, http }: Snooze if (!groupHash) { return; } - createAlertActionMutation.mutate({ + createAlertAction({ groupHash, actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, body: {}, diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx index c075823cb8363..23ed35d9fcef8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; describe('AlertEpisodeStatusCell', () => { it('renders status badge only when no action indicators', () => { @@ -27,7 +28,7 @@ describe('AlertEpisodeStatusCell', () => { ruleId: '1', groupHash: '1', lastAckAction: null, - lastSnoozeAction: 'snooze', + lastSnoozeAction: ALERT_EPISODE_ACTION_TYPE.SNOOZE, lastDeactivateAction: null, tags: [], }} @@ -44,7 +45,7 @@ describe('AlertEpisodeStatusCell', () => { episodeId: '1', ruleId: '1', groupHash: '1', - lastAckAction: 'ack', + lastAckAction: ALERT_EPISODE_ACTION_TYPE.ACK, lastSnoozeAction: null, lastDeactivateAction: null, tags: [], @@ -53,4 +54,22 @@ describe('AlertEpisodeStatusCell', () => { ); expect(screen.getByTestId('alertEpisodeStatusCellAckIndicator')).toBeInTheDocument(); }); + + it('renders inactive badge when last deactivate action is deactivate', () => { + render( + + ); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx index 6f98bcfe76d18..a815edd0dfa6f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import type { EpisodeAction } from '../../../types/episode_action'; import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; @@ -17,8 +18,8 @@ export interface AlertEpisodeStatusCellProps { } export function AlertEpisodeStatusCell({ status, episodeAction }: AlertEpisodeStatusCellProps) { - const isAcknowledged = episodeAction?.lastAckAction === 'ack'; - const isSnoozed = episodeAction?.lastSnoozeAction === 'snooze'; + const isAcknowledged = episodeAction?.lastAckAction === ALERT_EPISODE_ACTION_TYPE.ACK; + const isSnoozed = episodeAction?.lastSnoozeAction === ALERT_EPISODE_ACTION_TYPE.SNOOZE; return ( {isSnoozed && ( - + )} {isAcknowledged && ( - + )} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx index 5a0ebe4d187bd..6e95c38729b00 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -121,7 +121,7 @@ describe('useFetchEpisodeActions', () => { expect(result.current.actionsMap.get('ep-2')?.tags).toEqual(['solo']); }); - it('converts tags to empty arraywhen row tags are null', async () => { + it('converts tags to empty array when row tags are null', async () => { executeEsqlQueryMock.mockResolvedValue({ rows: [ { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts index 598ebc424961b..0bf76edd491dc 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateAckAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateAckAlertActionRoute.path.endsWith('/_ack')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = { episode_id: 'ep-1' }; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts index c54a305d9705c..b130610e72085 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateActivateAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateActivateAlertActionRoute.path.endsWith('/_activate')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = { reason: 'manual override' }; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts index 00b358b2ea338..730ed2c562519 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts @@ -28,14 +28,15 @@ describe('createAlertActionRouteForType', () => { }; it('creates a route class with expected static metadata', () => { + const suffix = '_tag'; const RouteClass = createAlertActionRouteForType({ actionType: 'tag', - pathSuffix: '_tag', + pathSuffix: suffix, bodySchema: createTagAlertActionBodySchema, }); expect(RouteClass.method).toBe('post'); - expect(RouteClass.path).toBe('/internal/alerting/v2/alerts/{group_hash}/action/_tag'); + expect(RouteClass.path).toBe(`/internal/alerting/v2/alerts/{group_hash}/action/${suffix}`); expect(RouteClass.validate).toBeDefined(); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts index fb3fadaca269d..d6a539b7104b2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateDeactivateAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateDeactivateAlertActionRoute.path.endsWith('/_deactivate')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = { reason: 'manual pause' }; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts index 7c43071567612..73250ce0891ab 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateSnoozeAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateSnoozeAlertActionRoute.path.endsWith('/_snooze')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = {}; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts index a604987c408b0..c8931b52b8d6c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateTagAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateTagAlertActionRoute.path.endsWith('/_tag')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = { tags: ['p1', 'p2'] }; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts index 2f0951485f70f..58d148e21f55d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateUnackAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateUnackAlertActionRoute.path.endsWith('/_unack')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = { episode_id: 'ep-1' }; const { request, response, alertActionsClient } = createDeps(body); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts index 4f0e3041304f2..491ebaf2c6a86 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts @@ -23,10 +23,6 @@ describe('CreateUnsnoozeAlertActionRoute', () => { return { request, response, alertActionsClient }; }; - it('has expected path suffix', () => { - expect(CreateUnsnoozeAlertActionRoute.path.endsWith('/_unsnooze')).toBe(true); - }); - it('injects action_type and returns noContent', async () => { const body = {}; const { request, response, alertActionsClient } = createDeps(body); From 280cd5eceb2ce891b8b7dd93c1e506f29638d0cd Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 11:53:36 +0200 Subject: [PATCH 13/28] Fix type check error with routes. --- .../alert_actions/create_alert_action_route_for_type.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts index 1b516cf9846f7..c08d97635b810 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts @@ -11,7 +11,7 @@ import { type CreateAlertActionBody, type CreateAlertActionParams, } from '@kbn/alerting-v2-schemas'; -import { Request, Response, type RouteHandler } from '@kbn/core-di-server'; +import { Request, Response, type RouteDefinition, type RouteHandler } from '@kbn/core-di-server'; import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; import { inject, injectable } from 'inversify'; @@ -40,7 +40,12 @@ export const createAlertActionRouteForType = < pathSuffix, bodySchema, mapBody, -}: CreateAlertActionRouteForTypeOptions) => { +}: CreateAlertActionRouteForTypeOptions): RouteDefinition< + CreateAlertActionParams, + unknown, + Omit, 'action_type'>, + 'post' +> => { type ActionBody = Omit, 'action_type'>; @injectable() From 486863b0afa6fac261cbd46512c3f6f276350fe4 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:23:26 +0000 Subject: [PATCH 14/28] Changes from node scripts/eslint_all_files --no-cache --fix --- .../observability/public/pages/alerts_v2/alerts_v2.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index 4df63728d7b43..3ff49e2674078 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -220,7 +220,9 @@ export function AlertsV2Page() { actions: (props) => { const episodeId = props.row.flattened['episode.id'] as string; const episodeAction = actionsMap.get(episodeId); - return ; + return ( + + ); }, tags: (props) => { const episodeId = props.row.flattened['episode.id'] as string; From 9d64b59bd18afaf259603a56552689ec432c8ab9 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 13:51:25 +0200 Subject: [PATCH 15/28] Update snooze button UI. --- .../alert_episode_snooze_form.test.tsx | 25 +--------- .../actions/alert_episode_snooze_form.tsx | 15 ------ .../actions/snooze_action_button.test.tsx | 31 ++++++------ .../actions/snooze_action_button.tsx | 49 ++++++++++--------- 4 files changed, 42 insertions(+), 78 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx index 1c61fef3b59c4..e80eeb7c86bc0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.test.tsx @@ -26,34 +26,11 @@ describe('AlertEpisodeSnoozeForm', () => { const user = userEvent.setup(); const onApplySnooze = jest.fn(); - render( - - ); + render(); await user.click(screen.getByRole('button', { name: '1 hour' })); expect(onApplySnooze).toHaveBeenCalledTimes(1); expect(Number.isNaN(Date.parse(onApplySnooze.mock.calls[0][0]))).toBe(false); }); - - it('shows cancel button only when isSnoozed is true', async () => { - const user = userEvent.setup(); - const onCancelSnooze = jest.fn(); - - render( - - ); - - await user.click(screen.getByRole('button', { name: 'Cancel snooze' })); - - expect(onCancelSnooze).toHaveBeenCalledTimes(1); - }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx index 3e2e080c7161a..a628dd180b76f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_snooze_form.tsx @@ -87,13 +87,9 @@ const ALERT_EPISODE_COMMON_SNOOZE_TIMES: Array<{ ]; export const AlertEpisodeSnoozeForm = ({ - isSnoozed, onApplySnooze, - onCancelSnooze, }: { - isSnoozed: boolean; onApplySnooze: (expiry: string) => void; - onCancelSnooze: () => void; }) => { const [durationValue, setDurationValue] = useState(1); const [durationUnit, setDurationUnit] = useState('h'); @@ -165,17 +161,6 @@ export const AlertEpisodeSnoozeForm = ({ ))}
- - {isSnoozed && ( - <> - - - {i18n.translate('xpack.alertingV2.episodesUi.snoozeForm.cancel', { - defaultMessage: 'Cancel snooze', - })} - - - )} ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx index 447e444ea48d5..85b72b7f62706 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx @@ -28,7 +28,7 @@ describe('SnoozeActionButton', () => { } as any); }); - it('renders Snooze with bellSlash when not snoozed', () => { + it('renders Snooze with bell when not snoozed (after unsnooze)', () => { render( { expect( screen .getByTestId('alertEpisodeSnoozeActionButton') - .querySelector('[data-euiicon-type="bellSlash"]') + .querySelector('[data-euiicon-type="bell"]') ).toBeInTheDocument(); }); - it('renders Snooze with bellSlash when previous action is undefined', () => { + it('renders Snooze with bell when previous action is undefined', () => { render(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); expect( screen .getByTestId('alertEpisodeSnoozeActionButton') - .querySelector('[data-euiicon-type="bellSlash"]') + .querySelector('[data-euiicon-type="bell"]') ).toBeInTheDocument(); }); - it('renders Unsnooze with bell when snoozed', () => { + it('renders Unsnooze with bellSlash when snoozed', () => { render( ); - expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Unsnooze'); + expect(screen.getByTestId('alertEpisodeUnsnoozeActionButton')).toHaveTextContent('Unsnooze'); expect( screen - .getByTestId('alertEpisodeSnoozeActionButton') - .querySelector('[data-euiicon-type="bell"]') + .getByTestId('alertEpisodeUnsnoozeActionButton') + .querySelector('[data-euiicon-type="bellSlash"]') ).toBeInTheDocument(); }); @@ -95,7 +95,7 @@ describe('SnoozeActionButton', () => { ); }); - it('shows cancel snooze and closes popover after click when snoozed', async () => { + it('calls unsnooze mutation when Unsnooze is clicked', async () => { const user = userEvent.setup(); render( { /> ); - await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); - - const cancelButton = screen.getByRole('button', { name: 'Cancel snooze' }); - - await user.click(cancelButton); + await user.click(screen.getByTestId('alertEpisodeUnsnoozeActionButton')); - await waitFor(() => - expect(screen.queryByTestId('alertEpisodeSnoozeForm')).not.toBeInTheDocument() - ); + expect(mutate).toHaveBeenCalledWith({ + groupHash: 'gh-1', + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, + }); }); it('calls snooze route mutation when applying from popover', async () => { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index 64da6a1686173..25c7d778ddf00 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -28,15 +28,30 @@ export function SnoozeActionButton({ const closePopover = () => setIsPopoverOpen(false); const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http); - const label = isSnoozed - ? i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { + return isSnoozed ? ( + { + if (!groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, + }); + }} + isDisabled={!groupHash} + isLoading={isLoading} + data-test-subj="alertEpisodeUnsnoozeActionButton" + > + {i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { defaultMessage: 'Unsnooze', - }) - : i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.snooze', { - defaultMessage: 'Snooze', - }); - - return ( + })} + + ) : ( - {label} + {i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.snooze', { + defaultMessage: 'Snooze', + })} } isOpen={isPopoverOpen} @@ -63,7 +80,6 @@ export function SnoozeActionButton({ panelStyle={{ width: 320 }} > { if (!groupHash) { return; @@ -75,17 +91,6 @@ export function SnoozeActionButton({ }); closePopover(); }} - onCancelSnooze={() => { - if (!groupHash) { - return; - } - createAlertAction({ - groupHash, - actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, - body: {}, - }); - closePopover(); - }} /> ); From bb9c7350683c126511ffcbfef321dccc9bf31252 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 14:26:51 +0200 Subject: [PATCH 16/28] Display snooze expiry date over the bell icon. --- .../alert_episode_actions_cell.test.tsx | 2 + .../actions/deactivate_action_button.test.tsx | 8 +-- .../actions/deactivate_action_button.tsx | 4 +- .../status/alert_episode_status_cell.test.tsx | 59 +++++++++++++++++-- .../status/alert_episode_status_cell.tsx | 37 +++++++++++- .../hooks/use_create_alert_action.ts | 2 +- .../hooks/use_fetch_episode_actions.test.tsx | 6 ++ .../hooks/use_fetch_episode_actions.ts | 6 +- .../types/episode_action.ts | 1 + 9 files changed, 110 insertions(+), 15 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index c94a9d14cfdfb..18318e1915b52 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -44,6 +44,7 @@ describe('AlertEpisodeActionsCell', () => { lastAckAction: null, lastDeactivateAction: null, lastSnoozeAction: null, + snoozeExpiry: null, tags: [], }} /> @@ -66,6 +67,7 @@ describe('AlertEpisodeActionsCell', () => { lastAckAction: null, lastDeactivateAction: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, lastSnoozeAction: null, + snoozeExpiry: null, tags: [], }} /> diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx index 84b8508f9a6db..d556926d4ffdc 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx @@ -27,14 +27,14 @@ describe('ResolveActionButton', () => { } as any); }); - it('renders Unresolve when not deactivated', () => { + it('renders Resolve when active', () => { render(); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( - 'Unresolve' + 'Resolve' ); }); - it('renders Resolve when deactivated', () => { + it('renders Unresolve when deactivated', () => { render( { /> ); expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( - 'Resolve' + 'Unresolve' ); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx index 4ade13e23fa4a..d6cd099cc70a5 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx @@ -31,10 +31,10 @@ export function ResolveActionButton({ const label = isDeactivated ? i18n.translate('xpack.alertingV2.episodesUi.resolveAction.activate', { - defaultMessage: 'Resolve', + defaultMessage: 'Unresolve', }) : i18n.translate('xpack.alertingV2.episodesUi.resolveAction.deactivate', { - defaultMessage: 'Unresolve', + defaultMessage: 'Resolve', }); const iconType = isDeactivated ? 'check' : 'cross'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx index 23ed35d9fcef8..05c14b737d4cb 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx @@ -6,13 +6,17 @@ */ import React from 'react'; +import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; +const renderWithI18n = (ui: React.ReactElement) => render({ui}); + describe('AlertEpisodeStatusCell', () => { it('renders status badge only when no action indicators', () => { - render(); + renderWithI18n(); expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByTestId('alertEpisodeStatusCell')).toBeInTheDocument(); expect(screen.queryByTestId('alertEpisodeStatusCellSnoozeIndicator')).not.toBeInTheDocument(); @@ -20,7 +24,7 @@ describe('AlertEpisodeStatusCell', () => { }); it('renders snoozed bellSlash badge when last snooze action is snooze', () => { - render( + renderWithI18n( { lastAckAction: null, lastSnoozeAction: ALERT_EPISODE_ACTION_TYPE.SNOOZE, lastDeactivateAction: null, + snoozeExpiry: null, tags: [], }} /> @@ -37,8 +42,52 @@ describe('AlertEpisodeStatusCell', () => { expect(screen.getByTestId('alertEpisodeStatusCellSnoozeIndicator')).toBeInTheDocument(); }); + it('shows snooze expiry in tooltip on hover when snoozeExpiry is set', async () => { + const user = userEvent.setup(); + renderWithI18n( + + ); + await user.hover(screen.getByTestId('alertEpisodeStatusCellSnoozeIndicator')); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(/snoozed until/i); + expect(tooltip).toHaveTextContent(/2035/); + }); + + it('shows generic snooze tooltip when snoozeExpiry is missing', async () => { + const user = userEvent.setup(); + renderWithI18n( + + ); + await user.hover(screen.getByTestId('alertEpisodeStatusCellSnoozeIndicator')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(/snoozed/i); + }); + it('renders checkCircle badge when acknowledged', () => { - render( + renderWithI18n( { lastAckAction: ALERT_EPISODE_ACTION_TYPE.ACK, lastSnoozeAction: null, lastDeactivateAction: null, + snoozeExpiry: null, tags: [], }} /> @@ -56,7 +106,7 @@ describe('AlertEpisodeStatusCell', () => { }); it('renders inactive badge when last deactivate action is deactivate', () => { - render( + renderWithI18n( { lastAckAction: null, lastSnoozeAction: null, lastDeactivateAction: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, + snoozeExpiry: null, tags: [], }} /> diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx index a815edd0dfa6f..15049ae4ecab3 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { FormattedDate, FormattedMessage } from '@kbn/i18n-react'; import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import type { EpisodeAction } from '../../../types/episode_action'; @@ -40,7 +41,39 @@ export function AlertEpisodeStatusCell({ status, episodeAction }: AlertEpisodeSt {isSnoozed && ( - + + ), + }} + /> + ) : ( + + ) + } + > + + )} {isAcknowledged && ( diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts index d33716cb3cdb3..f2acff81152c0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -28,7 +28,7 @@ export const useCreateAlertAction = (http: HttpStart) => { }); }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.all }); + await queryClient.invalidateQueries({ queryKey: queryKeys.actionsAll() }); }, }); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx index 6e95c38729b00..967ab05629734 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -60,6 +60,7 @@ describe('useFetchEpisodeActions', () => { last_ack_action: 'ack', last_deactivate_action: null, last_snooze_action: 'snooze', + snooze_expiry: '2035-01-02T12:00:00.000Z', tags: ['t1', 't2'], }, ], @@ -89,6 +90,7 @@ describe('useFetchEpisodeActions', () => { lastAckAction: 'ack', lastDeactivateAction: null, lastSnoozeAction: 'snooze', + snoozeExpiry: '2035-01-02T12:00:00.000Z', tags: ['t1', 't2'], }); }); @@ -103,6 +105,7 @@ describe('useFetchEpisodeActions', () => { last_ack_action: null, last_deactivate_action: null, last_snooze_action: null, + snooze_expiry: null, tags: 'solo', }, ], @@ -131,6 +134,7 @@ describe('useFetchEpisodeActions', () => { last_ack_action: null, last_deactivate_action: null, last_snooze_action: null, + snooze_expiry: null, tags: null, }, ], @@ -178,6 +182,7 @@ describe('useFetchEpisodeActions', () => { last_ack_action: 'ack', last_deactivate_action: null, last_snooze_action: null, + snooze_expiry: null, tags: [], }, { @@ -187,6 +192,7 @@ describe('useFetchEpisodeActions', () => { last_ack_action: 'unack', last_deactivate_action: null, last_snooze_action: null, + snooze_expiry: null, tags: [], }, ], diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index 7e4b46965f08d..a1f456df57b00 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -41,9 +41,10 @@ const buildBulkGetAlertActionsQuery = (episodeIds: string[]): string => { tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), - last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), + snooze_expiry = LAST(expiry, @timestamp) WHERE action_type IN ("snooze") BY episode_id, rule_id, group_hash - | KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action, tags + | KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action, snooze_expiry, tags `; }; @@ -72,6 +73,7 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode lastAckAction: (row.last_ack_action as string) ?? null, lastDeactivateAction: (row.last_deactivate_action as string) ?? null, lastSnoozeAction: (row.last_snooze_action as string) ?? null, + snoozeExpiry: (row.snooze_expiry as string) ?? null, tags: tagsFromRow(row.tags), }) ); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts index 64ced17f530d0..415847ddd86ef 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts @@ -12,5 +12,6 @@ export interface EpisodeAction { lastAckAction: string | null; lastDeactivateAction: string | null; lastSnoozeAction: string | null; + snoozeExpiry: string | null; tags: string[]; } From cf5410f776fd07e18f42789a8d4462fe41e8ffec Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 14:53:26 +0200 Subject: [PATCH 17/28] Ensure the episodes table reflects the action changes immediately. --- .../alert_actions_client.ts | 28 +++++++++++-------- .../storage_service/storage_service.test.ts | 17 +++++++++++ .../storage_service/storage_service.ts | 5 +++- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts index 4fa1f262fe39d..1458d70d1f07a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts @@ -43,16 +43,13 @@ export class AlertActionsClient { }), ]); - await this.storageService.bulkIndexDocs({ - index: ALERT_ACTIONS_DATA_STREAM, - docs: [ - this.buildAlertActionDocument({ - action: params.action, - alertEvent, - userProfileUid, - }), - ], - }); + await this.bulkIndexActions([ + this.buildAlertActionDocument({ + action: params.action, + alertEvent, + userProfileUid, + }), + ]); } public async createBulkActions( @@ -87,12 +84,21 @@ export class AlertActionsClient { .filter((doc): doc is AlertAction => doc !== undefined); if (docs.length > 0) { - await this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, docs }); + await this.bulkIndexActions(docs); } return { processed: docs.length, total: actions.length }; } + private async bulkIndexActions(docs: readonly AlertAction[]): Promise { + await this.storageService.bulkIndexDocs({ + index: ALERT_ACTIONS_DATA_STREAM, + docs, + // this ensures that the action is immediately visible to the user in the UI + refresh: 'wait_for', + }); + } + private async fetchLastAlertEventRecordsForActions( actions: BulkCreateAlertActionItemBody[] ): Promise { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.test.ts index e05a6f739f998..231b31ba0db89 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.test.ts @@ -72,6 +72,23 @@ describe('StorageService', () => { }); }); + it('should pass custom refresh option when provided', async () => { + const mockBulkResponse = { + items: [{ create: { _id: '1', status: 201 } }], + errors: false, + }; + + // @ts-expect-error - not all fields are used + mockEsClient.bulk.mockResolvedValue(mockBulkResponse); + + await storageService.bulkIndexDocs({ index, docs: [mockDocs[0]], refresh: 'wait_for' }); + + expect(mockEsClient.bulk).toHaveBeenCalledWith({ + operations: [{ create: { _index: index } }, mockDocs[0]], + refresh: 'wait_for', + }); + }); + it('should format operations correctly for bulk indexing', async () => { const mockBulkResponse = { items: [{ create: { _id: '1', status: 201 } }], diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.ts index e47c46ea0e50d..360a6676de5d2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/storage_service/storage_service.ts @@ -14,6 +14,8 @@ import { LoggerServiceToken } from '../logger_service/logger_service'; export interface BulkIndexDocsParams> { index: string; docs: readonly TDocument[]; + /** When `'wait_for'`, the bulk call blocks until the indexed documents are visible to search. Defaults to `false`. */ + refresh?: boolean | 'wait_for'; } export interface StorageServiceContract { @@ -32,6 +34,7 @@ export class StorageService implements StorageServiceContract { public async bulkIndexDocs>({ index, docs, + refresh = false, }: BulkIndexDocsParams): Promise { if (docs.length === 0) { return; @@ -47,7 +50,7 @@ export class StorageService implements StorageServiceContract { try { const response = await this.esClient.bulk({ operations, - refresh: false, + refresh, }); this.logBulkIndexResponse({ index, docsCount: docs.length, response }); From 6516e49bbdc3cc801e813c2ce780ff722d80b2cd Mon Sep 17 00:00:00 2001 From: adcoelho Date: Mon, 30 Mar 2026 15:43:28 +0200 Subject: [PATCH 18/28] Split episode and group actions in the episodes table. --- .../alert_episode_actions_cell.test.tsx | 20 +- .../actions/alert_episode_actions_cell.tsx | 25 ++- .../status/alert_episode_status_cell.test.tsx | 45 ++-- .../status/alert_episode_status_cell.tsx | 36 +++- .../hooks/use_create_alert_action.ts | 5 +- .../hooks/use_fetch_episode_actions.test.tsx | 81 +------ .../hooks/use_fetch_episode_actions.ts | 38 +--- .../hooks/use_fetch_group_actions.test.tsx | 201 ++++++++++++++++++ .../hooks/use_fetch_group_actions.ts | 94 ++++++++ .../alerting-v2-episodes-ui/query_keys.ts | 3 + .../types/{episode_action.ts => action.ts} | 5 + .../utils/execute_esql_query.test.ts | 12 +- .../utils/execute_esql_query.ts | 9 +- .../public/pages/alerts_v2/alerts_v2.tsx | 36 +++- 14 files changed, 444 insertions(+), 166 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/{episode_action.ts => action.ts} (86%) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index 18318e1915b52..3edae475d86d0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -32,16 +32,14 @@ describe('AlertEpisodeActionsCell', () => { expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument(); }); - it('opens popover and shows Unresolve from episode action state', async () => { + it('opens popover and shows Resolve when not deactivated', async () => { const user = userEvent.setup(); render( { await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Unresolve'); + ).toHaveTextContent('Resolve'); }); - it('shows Resolve in popover when episode is deactivated', async () => { + it('shows Unresolve in popover when group action is deactivated', async () => { const user = userEvent.setup(); render( { await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); expect( await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Resolve'); + ).toHaveTextContent('Unresolve'); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx index 6d33828daeade..31c1ffbc7d302 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx @@ -11,15 +11,24 @@ import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { SnoozeActionButton } from './snooze_action_button'; -import type { EpisodeAction } from '../../../types/episode_action'; +import type { EpisodeAction, GroupAction } from '../../../types/action'; import { ResolveActionButton } from './deactivate_action_button'; export interface AlertEpisodeActionsCellProps { + episodeId?: string; + groupHash?: string; episodeAction?: EpisodeAction; + groupAction?: GroupAction; http: HttpStart; } -export function AlertEpisodeActionsCell({ episodeAction, http }: AlertEpisodeActionsCellProps) { +export function AlertEpisodeActionsCell({ + episodeId, + groupHash, + episodeAction, + groupAction, + http, +}: AlertEpisodeActionsCellProps) { const [isMoreOpen, setIsMoreOpen] = useState(false); return ( @@ -33,15 +42,15 @@ export function AlertEpisodeActionsCell({ episodeAction, http }: AlertEpisodeAct @@ -76,8 +85,8 @@ export function AlertEpisodeActionsCell({ episodeAction, http }: AlertEpisodeAct > diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx index 05c14b737d4cb..a3f57d2b49b8e 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx @@ -23,15 +23,13 @@ describe('AlertEpisodeStatusCell', () => { expect(screen.queryByTestId('alertEpisodeStatusCellAckIndicator')).not.toBeInTheDocument(); }); - it('renders snoozed bellSlash badge when last snooze action is snooze', () => { + it('renders snoozed bellSlash badge when group action has snooze', () => { renderWithI18n( { renderWithI18n( { renderWithI18n( { expect(await screen.findByRole('tooltip')).toHaveTextContent(/snoozed/i); }); - it('renders checkCircle badge when acknowledged', () => { + it('renders checkCircle badge when acknowledged via episodeAction', () => { renderWithI18n( { ruleId: '1', groupHash: '1', lastAckAction: ALERT_EPISODE_ACTION_TYPE.ACK, - lastSnoozeAction: null, - lastDeactivateAction: null, - snoozeExpiry: null, - tags: [], }} /> ); expect(screen.getByTestId('alertEpisodeStatusCellAckIndicator')).toBeInTheDocument(); }); - it('renders inactive badge when last deactivate action is deactivate', () => { + it('shows acknowledged tooltip on hover', async () => { + const user = userEvent.setup(); renderWithI18n( { episodeId: '1', ruleId: '1', groupHash: '1', - lastAckAction: null, + lastAckAction: ALERT_EPISODE_ACTION_TYPE.ACK, + }} + /> + ); + await user.hover(screen.getByTestId('alertEpisodeStatusCellAckIndicator')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(/acknowledged/i); + }); + + it('renders inactive badge when group action has deactivate', () => { + renderWithI18n( + - + + } + > + + )} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts index f2acff81152c0..c1b66aebbcb2b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -28,7 +28,10 @@ export const useCreateAlertAction = (http: HttpStart) => { }); }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.actionsAll() }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.actionsAll() }), + queryClient.invalidateQueries({ queryKey: queryKeys.groupActionsAll() }), + ]); }, }); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx index 967ab05629734..3541e47285e98 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -50,7 +50,7 @@ describe('useFetchEpisodeActions', () => { expect(executeEsqlQueryMock).not.toHaveBeenCalled(); }); - it('fetches and builds actionsMap keyed by episode id', async () => { + it('fetches and builds episodeActionsMap keyed by episode id', async () => { executeEsqlQueryMock.mockResolvedValue({ rows: [ { @@ -58,10 +58,6 @@ describe('useFetchEpisodeActions', () => { rule_id: 'rule-1', group_hash: 'gh-1', last_ack_action: 'ack', - last_deactivate_action: null, - last_snooze_action: 'snooze', - snooze_expiry: '2035-01-02T12:00:00.000Z', - tags: ['t1', 't2'], }, ], } as unknown as Awaited>); @@ -80,79 +76,18 @@ describe('useFetchEpisodeActions', () => { expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); const call = executeEsqlQueryMock.mock.calls[0][0]; expect(call.query).toContain('ep-1'); + expect(call.query).toContain('"ack", "unack"'); expect(call.expressions).toBe(mockExpressions); - const action = result.current.actionsMap.get('ep-1'); + const action = result.current.episodeActionsMap.get('ep-1'); expect(action).toEqual({ episodeId: 'ep-1', ruleId: 'rule-1', groupHash: 'gh-1', lastAckAction: 'ack', - lastDeactivateAction: null, - lastSnoozeAction: 'snooze', - snoozeExpiry: '2035-01-02T12:00:00.000Z', - tags: ['t1', 't2'], }); }); - it('normalizes string tags into a single-element array', async () => { - executeEsqlQueryMock.mockResolvedValue({ - rows: [ - { - episode_id: 'ep-2', - rule_id: null, - group_hash: null, - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - snooze_expiry: null, - tags: 'solo', - }, - ], - } as unknown as Awaited>); - - const { result } = renderHook( - () => - useFetchEpisodeActions({ - episodeIds: ['ep-2'], - services: { expressions: mockExpressions }, - }), - { wrapper } - ); - - await waitFor(() => expect(result.current.actionsMap.has('ep-2')).toBe(true)); - expect(result.current.actionsMap.get('ep-2')?.tags).toEqual(['solo']); - }); - - it('converts tags to empty array when row tags are null', async () => { - executeEsqlQueryMock.mockResolvedValue({ - rows: [ - { - episode_id: 'ep-3', - rule_id: null, - group_hash: null, - last_ack_action: null, - last_deactivate_action: null, - last_snooze_action: null, - snooze_expiry: null, - tags: null, - }, - ], - } as unknown as Awaited>); - - const { result } = renderHook( - () => - useFetchEpisodeActions({ - episodeIds: ['ep-3'], - services: { expressions: mockExpressions }, - }), - { wrapper } - ); - - await waitFor(() => expect(result.current.actionsMap.has('ep-3')).toBe(true)); - expect(result.current.actionsMap.get('ep-3')?.tags).toEqual([]); - }); - it('escapes quotes in episode ids in the generated query', async () => { executeEsqlQueryMock.mockResolvedValue({ rows: [], @@ -180,20 +115,12 @@ describe('useFetchEpisodeActions', () => { rule_id: 'r1', group_hash: null, last_ack_action: 'ack', - last_deactivate_action: null, - last_snooze_action: null, - snooze_expiry: null, - tags: [], }, { episode_id: 'dup', rule_id: 'r2', group_hash: null, last_ack_action: 'unack', - last_deactivate_action: null, - last_snooze_action: null, - snooze_expiry: null, - tags: [], }, ], } as unknown as Awaited>); @@ -207,6 +134,6 @@ describe('useFetchEpisodeActions', () => { { wrapper } ); - await waitFor(() => expect(result.current.actionsMap.get('dup')?.ruleId).toBe('r2')); + await waitFor(() => expect(result.current.episodeActionsMap.get('dup')?.ruleId).toBe('r2')); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index a1f456df57b00..fea68df2a69ee 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -8,7 +8,7 @@ import { useMemo } from 'react'; import { useQuery } from '@kbn/react-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; -import type { EpisodeAction } from '../types/episode_action'; +import type { EpisodeAction } from '../types/action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; import { queryKeys } from '../query_keys'; @@ -17,34 +17,17 @@ const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; const escapeEsqlString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -const tagsFromRow = (value: unknown): string[] => { - if (value == null) { - return []; - } - if (typeof value === 'string') { - return [value]; - } - if (Array.isArray(value)) { - return value as string[]; - } - return []; -}; - -const buildBulkGetAlertActionsQuery = (episodeIds: string[]): string => { +const buildEpisodeActionsQuery = (episodeIds: string[]): string => { const escapedIds = episodeIds.map((id) => `"${escapeEsqlString(id)}"`).join(', '); return ` FROM ${ALERT_ACTIONS_DATA_STREAM} | WHERE episode_id IN (${escapedIds}) - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze", "tag") + | WHERE action_type IN ("ack", "unack") | STATS - tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), - last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), - last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), - last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), - snooze_expiry = LAST(expiry, @timestamp) WHERE action_type IN ("snooze") + last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack") BY episode_id, rule_id, group_hash - | KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action, snooze_expiry, tags + | KEEP episode_id, rule_id, group_hash, last_ack_action `; }; @@ -57,12 +40,13 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode const { data, isLoading } = useQuery({ queryKey: queryKeys.actions(episodeIds), queryFn: async ({ signal }) => { - const query = buildBulkGetAlertActionsQuery(episodeIds); + const query = buildEpisodeActionsQuery(episodeIds); const result = await executeEsqlQuery({ expressions: services.expressions, query, input: null, abortSignal: signal, + noCache: true, }); return result.rows.map( @@ -71,10 +55,6 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode ruleId: (row.rule_id as string) ?? null, groupHash: (row.group_hash as string) ?? null, lastAckAction: (row.last_ack_action as string) ?? null, - lastDeactivateAction: (row.last_deactivate_action as string) ?? null, - lastSnoozeAction: (row.last_snooze_action as string) ?? null, - snoozeExpiry: (row.snooze_expiry as string) ?? null, - tags: tagsFromRow(row.tags), }) ); }, @@ -82,7 +62,7 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode keepPreviousData: true, }); - const actionsMap = useMemo(() => { + const episodeActionsMap = useMemo(() => { const map = new Map(); if (data) { for (const action of data) { @@ -92,5 +72,5 @@ export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisode return map; }, [data]); - return { actionsMap, isLoading }; + return { episodeActionsMap, isLoading }; }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx new file mode 100644 index 0000000000000..e8b6a99a70fb6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx @@ -0,0 +1,201 @@ +/* + * 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, { type PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { useFetchGroupActions } from './use_fetch_group_actions'; + +jest.mock('../utils/execute_esql_query'); + +const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); +const mockExpressions = {} as ExpressionsStart; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useFetchGroupActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('does not call ES|QL when groupHashes is empty', () => { + renderHook( + () => + useFetchGroupActions({ + groupHashes: [], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + expect(executeEsqlQueryMock).not.toHaveBeenCalled(); + }); + + it('fetches and builds groupActionsMap keyed by group_hash', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + group_hash: 'gh-1', + rule_id: 'rule-1', + last_deactivate_action: 'deactivate', + last_snooze_action: 'snooze', + snooze_expiry: '2035-01-02T12:00:00.000Z', + tags: ['t1', 't2'], + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchGroupActions({ + groupHashes: ['gh-1'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); + const call = executeEsqlQueryMock.mock.calls[0][0]; + expect(call.query).toContain('gh-1'); + expect(call.query).toContain('group_hash'); + expect(call.query).not.toContain('"ack"'); + + const action = result.current.groupActionsMap.get('gh-1'); + expect(action).toEqual({ + groupHash: 'gh-1', + ruleId: 'rule-1', + lastDeactivateAction: 'deactivate', + lastSnoozeAction: 'snooze', + snoozeExpiry: '2035-01-02T12:00:00.000Z', + tags: ['t1', 't2'], + }); + }); + + it('normalizes string tags into a single-element array', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + group_hash: 'gh-2', + rule_id: null, + last_deactivate_action: null, + last_snooze_action: null, + snooze_expiry: null, + tags: 'solo', + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchGroupActions({ + groupHashes: ['gh-2'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.groupActionsMap.has('gh-2')).toBe(true)); + expect(result.current.groupActionsMap.get('gh-2')?.tags).toEqual(['solo']); + }); + + it('converts tags to empty array when row tags are null', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + group_hash: 'gh-3', + rule_id: null, + last_deactivate_action: null, + last_snooze_action: null, + snooze_expiry: null, + tags: null, + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchGroupActions({ + groupHashes: ['gh-3'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.groupActionsMap.has('gh-3')).toBe(true)); + expect(result.current.groupActionsMap.get('gh-3')?.tags).toEqual([]); + }); + + it('escapes quotes in group hashes in the generated query', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [], + } as unknown as Awaited>); + + renderHook( + () => + useFetchGroupActions({ + groupHashes: ['say"cheese'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(executeEsqlQueryMock).toHaveBeenCalled()); + const query = executeEsqlQueryMock.mock.calls[0][0].query; + expect(query).toContain('\\"'); + }); + + it('keeps the last row when duplicate group hashes are returned', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + group_hash: 'dup', + rule_id: 'r1', + last_deactivate_action: null, + last_snooze_action: 'snooze', + snooze_expiry: null, + tags: [], + }, + { + group_hash: 'dup', + rule_id: 'r2', + last_deactivate_action: 'deactivate', + last_snooze_action: null, + snooze_expiry: null, + tags: [], + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchGroupActions({ + groupHashes: ['dup'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.groupActionsMap.get('dup')?.ruleId).toBe('r2')); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts new file mode 100644 index 0000000000000..874916bc6aa31 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { GroupAction } from '../types/action'; +import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { queryKeys } from '../query_keys'; + +const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; + +const escapeEsqlString = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +const tagsFromRow = (value: unknown): string[] => { + if (value == null) { + return []; + } + if (typeof value === 'string') { + return [value]; + } + if (Array.isArray(value)) { + return value as string[]; + } + return []; +}; + +const buildGroupActionsQuery = (groupHashes: string[]): string => { + const escaped = groupHashes.map((h) => `"${escapeEsqlString(h)}"`).join(', '); + + return ` + FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE group_hash IN (${escaped}) + | WHERE action_type IN ("deactivate", "activate", "snooze", "unsnooze", "tag") + | STATS + tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), + last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), + snooze_expiry = LAST(expiry, @timestamp) WHERE action_type IN ("snooze") + BY group_hash, rule_id + | KEEP group_hash, rule_id, last_deactivate_action, last_snooze_action, snooze_expiry, tags + `; +}; + +export interface UseFetchGroupActionsOptions { + groupHashes: string[]; + services: { expressions: ExpressionsStart }; +} + +export const useFetchGroupActions = ({ groupHashes, services }: UseFetchGroupActionsOptions) => { + const { data, isLoading } = useQuery({ + queryKey: queryKeys.groupActions(groupHashes), + queryFn: async ({ signal }) => { + const query = buildGroupActionsQuery(groupHashes); + const result = await executeEsqlQuery({ + expressions: services.expressions, + query, + input: null, + abortSignal: signal, + noCache: true, + }); + + return result.rows.map( + (row): GroupAction => ({ + groupHash: row.group_hash as string, + ruleId: (row.rule_id as string) ?? null, + lastDeactivateAction: (row.last_deactivate_action as string) ?? null, + lastSnoozeAction: (row.last_snooze_action as string) ?? null, + snoozeExpiry: (row.snooze_expiry as string) ?? null, + tags: tagsFromRow(row.tags), + }) + ); + }, + enabled: groupHashes.length > 0, + keepPreviousData: true, + }); + + const groupActionsMap = useMemo(() => { + const map = new Map(); + if (data) { + for (const action of data) { + map.set(action.groupHash, action); + } + } + return map; + }, [data]); + + return { groupActionsMap, isLoading }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts index 686e0f056bfb1..76194e3d86149 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/query_keys.ts @@ -10,4 +10,7 @@ export const queryKeys = { list: (pageSize: number) => [...queryKeys.all, 'list', pageSize] as const, actionsAll: () => [...queryKeys.all, 'actions'] as const, actions: (episodeIds: string[]) => [...queryKeys.actionsAll(), ...episodeIds] as const, + groupActionsAll: () => [...queryKeys.all, 'group-actions'] as const, + groupActions: (groupHashes: string[]) => + [...queryKeys.groupActionsAll(), ...groupHashes] as const, }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.ts similarity index 86% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.ts index 415847ddd86ef..797ded94c7192 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/episode_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.ts @@ -10,6 +10,11 @@ export interface EpisodeAction { ruleId: string | null; groupHash: string | null; lastAckAction: string | null; +} + +export interface GroupAction { + groupHash: string; + ruleId: string | null; lastDeactivateAction: string | null; lastSnoozeAction: string | null; snoozeExpiry: string | null; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts index 29c3bb03bdbeb..fdfaa572ccc61 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.test.ts @@ -45,7 +45,8 @@ describe('executeEsqlQuery', () => { expect(mockExpressionsService.execute).toHaveBeenCalledWith( "esql 'FROM index | STATS count() BY status'", - null + null, + undefined ); expect(result).toEqual(mockDatatable); }); @@ -72,7 +73,11 @@ describe('executeEsqlQuery', () => { input, }); - expect(mockExpressionsService.execute).toHaveBeenCalledWith("esql 'FROM logs'", input); + expect(mockExpressionsService.execute).toHaveBeenCalledWith( + "esql 'FROM logs'", + input, + undefined + ); }); it('should handle query with single quotes correctly', async () => { @@ -97,7 +102,8 @@ describe('executeEsqlQuery', () => { expect(mockExpressionsService.execute).toHaveBeenCalledWith( "esql 'FROM index | WHERE status == 'active''", - null + null, + undefined ); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts index 7d2b0093876f1..fd35624058823 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/execute_esql_query.ts @@ -13,6 +13,8 @@ export interface ExecuteEsqlQueryOptions { query: string; abortSignal?: AbortSignal; input: Input; + /** When true, passes `allowCache: false` to `expressions.execute` to bypass expression-layer caching. */ + noCache?: boolean; } /** @@ -24,8 +26,13 @@ export const executeEsqlQuery = ({ query, input, abortSignal, + noCache, }: ExecuteEsqlQueryOptions) => { - const executionContract = expressions.execute(`esql '${query}'`, input); + const executionContract = expressions.execute( + `esql '${query}'`, + input, + noCache ? { allowCache: false } : undefined + ); abortSignal?.addEventListener('abort', (e) => { executionContract.cancel((e.target as AbortSignal)?.reason); }); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index 3ff49e2674078..e0296135e2a8c 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -32,6 +32,7 @@ import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hook import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; import { useFetchEpisodeActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions'; +import { useFetchGroupActions } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_group_actions'; import { AlertEpisodeStatusCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell'; import { AlertEpisodeActionsCell } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell'; import { AlertEpisodeTags } from '@kbn/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags'; @@ -127,7 +128,13 @@ export function AlertsV2Page() { [rows] ); - const { actionsMap } = useFetchEpisodeActions({ episodeIds, services }); + const groupHashes = useMemo( + () => [...new Set(rows.map((row) => row.flattened.group_hash as string).filter(Boolean))], + [rows] + ); + + const { episodeActionsMap } = useFetchEpisodeActions({ episodeIds, services }); + const { groupActionsMap } = useFetchGroupActions({ groupHashes, services }); const onSetColumns = useCallback((cols: string[], _hideTimeCol: boolean) => { setColumns(cols); @@ -213,22 +220,35 @@ export function AlertsV2Page() { 'episode.status': (props) => { const status = props.row.flattened[props.columnId] as AlertEpisodeStatus; const episodeId = props.row.flattened['episode.id'] as string; - const episodeAction = actionsMap.get(episodeId); + const groupHash = props.row.flattened.group_hash as string; - return ; + return ( + + ); }, actions: (props) => { const episodeId = props.row.flattened['episode.id'] as string; - const episodeAction = actionsMap.get(episodeId); + const groupHash = props.row.flattened.group_hash as string; + return ( - + ); }, tags: (props) => { - const episodeId = props.row.flattened['episode.id'] as string; - const episodeAction = actionsMap.get(episodeId); + const groupHash = props.row.flattened.group_hash as string; + const groupAction = groupActionsMap.get(groupHash); - return ; + return ; }, 'rule.id': (props) => { if (!Object.keys(rulesIndex).length && isLoadingRules) { From 208b28e44c5dbaaca2bfcabdbddaabd08164d9f2 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 10:43:34 +0200 Subject: [PATCH 19/28] Address PR comments. --- .../hooks/use_fetch_episode_actions.ts | 20 +--------- .../hooks/use_fetch_group_actions.ts | 36 +---------------- .../queries/build_episode_actions_query.ts | 23 +++++++++++ .../queries/build_group_actions_query.ts | 39 +++++++++++++++++++ .../utils/queries/constants.ts | 8 ++++ .../src/alert_action_schema.ts | 18 ++++----- 6 files changed, 81 insertions(+), 63 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/constants.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index fea68df2a69ee..f845c19c77997 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -11,25 +11,7 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { EpisodeAction } from '../types/action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; import { queryKeys } from '../query_keys'; - -const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; - -const escapeEsqlString = (value: string): string => - value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - -const buildEpisodeActionsQuery = (episodeIds: string[]): string => { - const escapedIds = episodeIds.map((id) => `"${escapeEsqlString(id)}"`).join(', '); - - return ` - FROM ${ALERT_ACTIONS_DATA_STREAM} - | WHERE episode_id IN (${escapedIds}) - | WHERE action_type IN ("ack", "unack") - | STATS - last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack") - BY episode_id, rule_id, group_hash - | KEEP episode_id, rule_id, group_hash, last_ack_action - `; -}; +import { buildEpisodeActionsQuery } from '../utils/queries/build_episode_actions_query'; export interface UseFetchEpisodeActionsOptions { episodeIds: string[]; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts index 874916bc6aa31..ccaa57ee29250 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts @@ -11,41 +11,7 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { GroupAction } from '../types/action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; import { queryKeys } from '../query_keys'; - -const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; - -const escapeEsqlString = (value: string): string => - value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - -const tagsFromRow = (value: unknown): string[] => { - if (value == null) { - return []; - } - if (typeof value === 'string') { - return [value]; - } - if (Array.isArray(value)) { - return value as string[]; - } - return []; -}; - -const buildGroupActionsQuery = (groupHashes: string[]): string => { - const escaped = groupHashes.map((h) => `"${escapeEsqlString(h)}"`).join(', '); - - return ` - FROM ${ALERT_ACTIONS_DATA_STREAM} - | WHERE group_hash IN (${escaped}) - | WHERE action_type IN ("deactivate", "activate", "snooze", "unsnooze", "tag") - | STATS - tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), - last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), - last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), - snooze_expiry = LAST(expiry, @timestamp) WHERE action_type IN ("snooze") - BY group_hash, rule_id - | KEEP group_hash, rule_id, last_deactivate_action, last_snooze_action, snooze_expiry, tags - `; -}; +import { buildGroupActionsQuery, tagsFromRow } from '../utils/queries/build_group_actions_query'; export interface UseFetchGroupActionsOptions { groupHashes: string[]; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts new file mode 100644 index 0000000000000..2e84f29b26161 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts @@ -0,0 +1,23 @@ +/* + * 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 { esql } from '@elastic/esql'; + +import { ALERT_ACTIONS_DATA_STREAM } from './constants'; + +export const buildEpisodeActionsQuery = (episodeIds: string[]): string => { + const episodeIdLiterals = episodeIds.map((id) => esql.str(id)); + + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE episode_id IN (${episodeIdLiterals}) + | WHERE action_type IN ("ack", "unack") + | STATS + last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack") + BY episode_id, rule_id, group_hash + | KEEP episode_id, rule_id, group_hash, last_ack_action + `.print('basic'); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts new file mode 100644 index 0000000000000..6f5788e4af91e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts @@ -0,0 +1,39 @@ +/* + * 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 { esql } from '@elastic/esql'; + +import { ALERT_ACTIONS_DATA_STREAM } from './constants'; + +export const tagsFromRow = (value: unknown): string[] => { + if (value == null) { + return []; + } + if (typeof value === 'string') { + return [value]; + } + if (Array.isArray(value)) { + return value as string[]; + } + return []; +}; + +export const buildGroupActionsQuery = (groupHashes: string[]): string => { + const groupHashLiterals = groupHashes.map((h) => esql.str(h)); + + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE group_hash IN (${groupHashLiterals}) + | WHERE action_type IN ("deactivate", "activate", "snooze", "unsnooze", "tag") + | STATS + tags = LAST(tags, @timestamp) WHERE action_type IN ("tag"), + last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), + snooze_expiry = LAST(expiry, @timestamp) WHERE action_type IN ("snooze") + BY group_hash, rule_id + | KEEP group_hash, rule_id, last_deactivate_action, last_snooze_action, snooze_expiry, tags + `.print('basic'); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/constants.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/constants.ts new file mode 100644 index 0000000000000..98f418ed7d9fb --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ALERT_ACTIONS_DATA_STREAM = '.alert-actions'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts index 5fa5569b6f48f..285d317f00929 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts @@ -7,15 +7,15 @@ import { z } from '@kbn/zod/v4'; -export const ALERT_EPISODE_ACTION_TYPE = { - ACK: 'ack', - UNACK: 'unack', - TAG: 'tag', - SNOOZE: 'snooze', - UNSNOOZE: 'unsnooze', - ACTIVATE: 'activate', - DEACTIVATE: 'deactivate', -} as const; +export enum ALERT_EPISODE_ACTION_TYPE { + ACK = 'ack', + UNACK = 'unack', + TAG = 'tag', + SNOOZE = 'snooze', + UNSNOOZE = 'unsnooze', + ACTIVATE = 'activate', + DEACTIVATE = 'deactivate', +} export type AlertEpisodeActionType = (typeof ALERT_EPISODE_ACTION_TYPE)[keyof typeof ALERT_EPISODE_ACTION_TYPE]; From c64e73104966e520313c22f91296b44fbeb40387 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 11:00:44 +0200 Subject: [PATCH 20/28] Change new apis to public. --- .../hooks/use_create_alert_action.ts | 4 ++-- .../create_ack_alert_action_route.ts | 7 +++++-- .../create_activate_alert_action_route.ts | 7 +++++-- .../create_alert_action_route_for_type.test.ts | 15 +++++++++------ .../create_alert_action_route_for_type.ts | 12 +++++++++--- .../create_deactivate_alert_action_route.ts | 7 +++++-- .../create_snooze_alert_action_route.ts | 7 +++++-- .../create_tag_alert_action_route.ts | 7 +++++-- .../create_unack_alert_action_route.ts | 7 +++++-- .../create_unsnooze_alert_action_route.ts | 7 +++++-- 10 files changed, 55 insertions(+), 25 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts index c1b66aebbcb2b..d9816c7e38478 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -10,7 +10,7 @@ import { useMutation, useQueryClient } from '@kbn/react-query'; import type { AlertEpisodeActionType } from '@kbn/alerting-v2-schemas'; import { queryKeys } from '../query_keys'; -const INTERNAL_ALERTING_V2_ALERT_API_PATH = '/internal/alerting/v2/alerts'; +const ALERTING_V2_ALERT_API_PATH = '/api/alerting/v2/alerts'; interface CreateAlertActionParams { groupHash: string; @@ -23,7 +23,7 @@ export const useCreateAlertAction = (http: HttpStart) => { return useMutation({ mutationFn: async ({ groupHash, actionType, body = {} }: CreateAlertActionParams) => { - await http.post(`${INTERNAL_ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, { + await http.post(`${ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, { body: JSON.stringify(body), }); }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts index 747f277c3afeb..878af3fc8f66b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createAckAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createAckAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateAckAlertActionRoute = createAlertActionRouteForType({ - actionType: 'ack', + actionType: ALERT_EPISODE_ACTION_TYPE.ACK, pathSuffix: '_ack', bodySchema: createAckAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts index 6667657c4c301..f36b258e7d20e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createActivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createActivateAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateActivateAlertActionRoute = createAlertActionRouteForType({ - actionType: 'activate', + actionType: ALERT_EPISODE_ACTION_TYPE.ACTIVATE, pathSuffix: '_activate', bodySchema: createActivateAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts index 730ed2c562519..009e91ad8bc09 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { createTagAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createTagAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; describe('createAlertActionRouteForType', () => { @@ -30,19 +33,19 @@ describe('createAlertActionRouteForType', () => { it('creates a route class with expected static metadata', () => { const suffix = '_tag'; const RouteClass = createAlertActionRouteForType({ - actionType: 'tag', + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, pathSuffix: suffix, bodySchema: createTagAlertActionBodySchema, }); expect(RouteClass.method).toBe('post'); - expect(RouteClass.path).toBe(`/internal/alerting/v2/alerts/{group_hash}/action/${suffix}`); + expect(RouteClass.path).toBe(`/api/alerting/v2/alerts/{group_hash}/action/${suffix}`); expect(RouteClass.validate).toBeDefined(); }); it('injects inferred action_type into createAction payload', async () => { const RouteClass = createAlertActionRouteForType({ - actionType: 'tag', + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, pathSuffix: '_tag', bodySchema: createTagAlertActionBodySchema, }); @@ -63,7 +66,7 @@ describe('createAlertActionRouteForType', () => { it('maps thrown error to customError response', async () => { const RouteClass = createAlertActionRouteForType({ - actionType: 'tag', + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, pathSuffix: '_tag', bodySchema: createTagAlertActionBodySchema, }); @@ -79,7 +82,7 @@ describe('createAlertActionRouteForType', () => { it('applies body mapper when provided', async () => { const RouteClass = createAlertActionRouteForType({ - actionType: 'tag', + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, pathSuffix: '_tag', bodySchema: createTagAlertActionBodySchema, mapBody: (body) => ({ ...body, tags: ['mapped'] }), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts index c08d97635b810..1ed6d26920860 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts @@ -18,7 +18,7 @@ import { inject, injectable } from 'inversify'; import type { z } from '@kbn/zod/v4'; import { AlertActionsClient } from '../../lib/alert_actions_client'; import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; -import { INTERNAL_ALERTING_V2_ALERT_API_PATH } from '../constants'; +import { ALERTING_V2_ALERT_API_PATH } from '../constants'; interface CreateAlertActionRouteForTypeOptions< TAction extends CreateAlertActionBody['action_type'] @@ -51,13 +51,19 @@ export const createAlertActionRouteForType = < @injectable() class CreateTypedAlertActionRoute implements RouteHandler { static method = 'post' as const; - static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/{group_hash}/action/${pathSuffix}`; + static path = `${ALERTING_V2_ALERT_API_PATH}/{group_hash}/action/${pathSuffix}`; static security: RouteSecurity = { authz: { requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.write], }, }; - static options = { access: 'internal' } as const; + static options = { + access: 'public', + summary: `Create an alert ${pathSuffix} action`, + description: 'Create an action for a specific alert group.', + tags: ['oas-tag:alerting-v2'], + availability: { stability: 'experimental' }, + } as const; static validate = { request: { params: buildRouteValidationWithZod(createAlertActionParamsSchema), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts index 3d45738dc17bf..ba15e7a697743 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createDeactivateAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createDeactivateAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateDeactivateAlertActionRoute = createAlertActionRouteForType({ - actionType: 'deactivate', + actionType: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, pathSuffix: '_deactivate', bodySchema: createDeactivateAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts index 47d71fc8158ec..cdba68e22c088 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createSnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createSnoozeAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateSnoozeAlertActionRoute = createAlertActionRouteForType({ - actionType: 'snooze', + actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, pathSuffix: '_snooze', bodySchema: createSnoozeAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts index 651636e4834d7..7cad9b63c2cd2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createTagAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createTagAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateTagAlertActionRoute = createAlertActionRouteForType({ - actionType: 'tag', + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, pathSuffix: '_tag', bodySchema: createTagAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts index 9f1902219c7d4..4355ccf23dcc2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createUnackAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createUnackAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateUnackAlertActionRoute = createAlertActionRouteForType({ - actionType: 'unack', + actionType: ALERT_EPISODE_ACTION_TYPE.UNACK, pathSuffix: '_unack', bodySchema: createUnackAlertActionBodySchema, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts index 6548b6fc5f1b6..add4589d88768 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { createUnsnoozeAlertActionBodySchema } from '@kbn/alerting-v2-schemas'; +import { + ALERT_EPISODE_ACTION_TYPE, + createUnsnoozeAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; export const CreateUnsnoozeAlertActionRoute = createAlertActionRouteForType({ - actionType: 'unsnooze', + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, pathSuffix: '_unsnooze', bodySchema: createUnsnoozeAlertActionBodySchema, }); From e28a5808960898a6968fee3d7c363e16bfc77480 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 11:27:20 +0200 Subject: [PATCH 21/28] Fix type errors. --- .../alert_episode_status_badge.test.tsx | 9 ++-- .../status/alert_episode_status_badge.tsx | 10 ++-- .../status/alert_episode_status_cell.test.tsx | 16 +++--- .../status/alert_episode_status_cell.tsx | 9 ++-- .../src/alert_action_schema.ts | 9 ++++ .../alert_actions_client.test.ts | 50 +++++++++++++------ 6 files changed, 68 insertions(+), 35 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx index 3c155c61b9f08..86dd69287babf 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.test.tsx @@ -8,31 +8,32 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; +import { ALERT_EPISODE_STATUS } from '@kbn/alerting-v2-schemas'; describe('AlertEpisodeStatusBadge', () => { it('renders an inactive badge', () => { - render(); + render(); const badge = screen.getByText('Inactive'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a pending badge', () => { - render(); + render(); const badge = screen.getByText('Pending'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders an active badge', () => { - render(); + render(); const badge = screen.getByText('Active'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); }); it('renders a recovering badge', () => { - render(); + render(); const badge = screen.getByText('Recovering'); expect(badge).toBeInTheDocument(); expect(badge).toHaveAttribute('class', expect.stringContaining('euiBadge')); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx index 111f44fae3bc3..aa78c404dcc2b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_badge.tsx @@ -8,7 +8,7 @@ import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/datastreams/alert_events'; +import { ALERT_EPISODE_STATUS, type AlertEpisodeStatus } from '@kbn/alerting-v2-schemas'; export interface AlertEpisodeStatusBadgeProps { status: AlertEpisodeStatus; @@ -18,7 +18,7 @@ export interface AlertEpisodeStatusBadgeProps { * Renders a badge indicating the status of an alerting episode. */ export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps) => { - if (status === 'inactive') { + if (status === ALERT_EPISODE_STATUS.INACTIVE) { return ( {i18n.translate('xpack.alertingV2EpisodesUi.inactiveStatusBadgeLabel', { @@ -27,7 +27,7 @@ export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps ); } - if (status === 'pending') { + if (status === ALERT_EPISODE_STATUS.PENDING) { return ( {i18n.translate('xpack.alertingV2EpisodesUi.pendingStatusBadgeLabel', { @@ -36,7 +36,7 @@ export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps ); } - if (status === 'active') { + if (status === ALERT_EPISODE_STATUS.ACTIVE) { return ( {i18n.translate('xpack.alertingV2EpisodesUi.activeStatusBadgeLabel', { @@ -45,7 +45,7 @@ export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps ); } - if (status === 'recovering') { + if (status === ALERT_EPISODE_STATUS.RECOVERING) { return ( {i18n.translate('xpack.alertingV2EpisodesUi.recoveringStatusBadgeLabel', { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx index a3f57d2b49b8e..2e67194c964f1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/status/alert_episode_status_cell.test.tsx @@ -10,13 +10,13 @@ import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; -import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; +import { ALERT_EPISODE_ACTION_TYPE, ALERT_EPISODE_STATUS } from '@kbn/alerting-v2-schemas'; const renderWithI18n = (ui: React.ReactElement) => render({ui}); describe('AlertEpisodeStatusCell', () => { it('renders status badge only when no action indicators', () => { - renderWithI18n(); + renderWithI18n(); expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByTestId('alertEpisodeStatusCell')).toBeInTheDocument(); expect(screen.queryByTestId('alertEpisodeStatusCellSnoozeIndicator')).not.toBeInTheDocument(); @@ -26,7 +26,7 @@ describe('AlertEpisodeStatusCell', () => { it('renders snoozed bellSlash badge when group action has snooze', () => { renderWithI18n( { const user = userEvent.setup(); renderWithI18n( { const user = userEvent.setup(); renderWithI18n( { it('renders checkCircle badge when acknowledged via episodeAction', () => { renderWithI18n( { const user = userEvent.setup(); renderWithI18n( { it('renders inactive badge when group action has deactivate', () => { renderWithI18n( diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts index 285d317f00929..c5654785cbf07 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts @@ -7,6 +7,15 @@ import { z } from '@kbn/zod/v4'; +export enum ALERT_EPISODE_STATUS { + INACTIVE = 'inactive', + PENDING = 'pending', + ACTIVE = 'active', + RECOVERING = 'recovering', +} + +export type AlertEpisodeStatus = (typeof ALERT_EPISODE_STATUS)[keyof typeof ALERT_EPISODE_STATUS]; + export enum ALERT_EPISODE_ACTION_TYPE { ACK = 'ack', UNACK = 'unack', diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts index bc4dc4f372668..2b7bcb0759dc3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts @@ -7,7 +7,8 @@ import type { UserProfileServiceStart } from '@kbn/core-user-profile-server'; import type { UserService } from '../services/user_service/user_service'; -import type { CreateAlertActionBody } from '@kbn/alerting-v2-schemas'; +import type { BulkCreateAlertActionItemBody } from '@kbn/alerting-v2-schemas'; +import { ALERT_EPISODE_ACTION_TYPE, type CreateAlertActionBody } from '@kbn/alerting-v2-schemas'; import { createQueryService } from '../services/query_service/query_service.mock'; import { createStorageService } from '../services/storage_service/storage_service.mock'; import { createUserProfile, createUserService } from '../services/user_service/user_service.mock'; @@ -39,7 +40,7 @@ describe('AlertActionsClient', () => { describe('createAction', () => { const actionData: CreateAlertActionBody = { - action_type: 'ack', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, episode_id: 'episode-1', }; @@ -60,7 +61,7 @@ describe('AlertActionsClient', () => { expect(docs).toHaveLength(1); expect(docs[0]).toMatchObject({ group_hash: 'test-group-hash', - action_type: 'ack', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, episode_id: 'episode-1', rule_id: 'test-rule-id', last_series_event_timestamp: '2025-01-01T00:00:00.000Z', @@ -86,7 +87,7 @@ describe('AlertActionsClient', () => { it('should handle action with episode_id', async () => { const actionWithEpisode: CreateAlertActionBody = { - action_type: 'ack', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, episode_id: 'episode-2', }; @@ -108,7 +109,7 @@ describe('AlertActionsClient', () => { it('should handle action with tags', async () => { const tagAction: CreateAlertActionBody = { - action_type: 'tag', + action_type: ALERT_EPISODE_ACTION_TYPE.TAG, tags: ['critical', 'network'], }; @@ -126,7 +127,7 @@ describe('AlertActionsClient', () => { expect(docs).toHaveLength(1); expect(docs[0]).toMatchObject({ group_hash: 'test-group-hash', - action_type: 'tag', + action_type: ALERT_EPISODE_ACTION_TYPE.TAG, tags: ['critical', 'network'], rule_id: 'test-rule-id', actor: 'test-uid', @@ -158,9 +159,13 @@ describe('AlertActionsClient', () => { describe('createBulkActions', () => { it('should process all actions successfully', async () => { - const actions = [ - { group_hash: 'group-hash-1', action_type: 'ack' as const, episode_id: 'episode-1' }, - { group_hash: 'group-hash-2', action_type: 'snooze' as const }, + const actions: BulkCreateAlertActionItemBody[] = [ + { + group_hash: 'group-hash-1', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, + episode_id: 'episode-1', + }, + { group_hash: 'group-hash-2', action_type: ALERT_EPISODE_ACTION_TYPE.SNOOZE }, ]; queryServiceEsClient.esql.query.mockResolvedValueOnce( @@ -181,9 +186,17 @@ describe('AlertActionsClient', () => { }); it('should handle partial failures and return correct counts', async () => { - const actions = [ - { group_hash: 'group-hash-1', action_type: 'ack' as const, episode_id: 'episode-1' }, - { group_hash: 'unknown-group-hash', action_type: 'ack' as const, episode_id: 'episode-1' }, + const actions: BulkCreateAlertActionItemBody[] = [ + { + group_hash: 'group-hash-1', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, + episode_id: 'episode-1', + }, + { + group_hash: 'unknown-group-hash', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, + episode_id: 'episode-1', + }, ]; queryServiceEsClient.esql.query.mockResolvedValueOnce( @@ -201,9 +214,16 @@ describe('AlertActionsClient', () => { }); it('should return processed 0 when all actions fail', async () => { - const actions = [ - { group_hash: 'unknown-1', action_type: 'ack' as const, episode_id: 'episode-1' }, - { group_hash: 'unknown-2', action_type: 'snooze' as const }, + const actions: BulkCreateAlertActionItemBody[] = [ + { + group_hash: 'unknown-1', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, + episode_id: 'episode-1', + }, + { + group_hash: 'unknown-2', + action_type: ALERT_EPISODE_ACTION_TYPE.SNOOZE, + }, ]; queryServiceEsClient.esql.query.mockResolvedValueOnce(getBulkAlertEventsESQLResponse([])); From c8f69bbcf18bc56ef013b9852cc7fc9dc2614165 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 11:42:24 +0200 Subject: [PATCH 22/28] Fix flaky action tests. --- .../alerting_v2/bulk_create_alert_action.ts | 4 ++-- .../apis/alerting_v2/create_alert_action.ts | 4 ++-- .../fixtures/dispatcher_action_types.ts | 19 +++++++++++++++++++ .../apis/alerting_v2/fixtures/index.ts | 1 + 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/dispatcher_action_types.ts diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts index b5a5d100a5461..78c3bbc193a6f 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; import type { RoleCredentials } from '../../services'; -import { createAlertEvent, indexAlertEvents } from './fixtures'; +import { createAlertEvent, DISPATCHER_SYSTEM_ACTION_TYPES, indexAlertEvents } from './fixtures'; const BULK_ALERT_ACTION_API_PATH = '/api/alerting/v2/alerts/action/_bulk'; const ALERTING_EVENTS_INDEX = '.rule-events'; @@ -59,7 +59,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { index: ALERTING_ACTIONS_INDEX, query: { bool: { - must_not: [{ terms: { action_type: ['fire', 'suppress'] } }], + must_not: [{ terms: { action_type: [...DISPATCHER_SYSTEM_ACTION_TYPES] } }], filter: [{ terms: { rule_id: ruleIds } }], }, }, diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts index bdeb59d332e93..a451c5681aecc 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; import type { RoleCredentials } from '../../services'; -import { createAlertEvent, indexAlertEvents } from './fixtures'; +import { createAlertEvent, DISPATCHER_SYSTEM_ACTION_TYPES, indexAlertEvents } from './fixtures'; const ALERT_ACTION_API_PATH = '/api/alerting/v2/alerts'; const ALERTING_EVENTS_INDEX = '.rule-events'; @@ -59,7 +59,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { index: ALERTING_ACTIONS_INDEX, query: { bool: { - must_not: [{ terms: { action_type: ['fire', 'suppress'] } }], + must_not: [{ terms: { action_type: [...DISPATCHER_SYSTEM_ACTION_TYPES] } }], filter: [{ terms: { rule_id: ruleIds } }], }, }, diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/dispatcher_action_types.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/dispatcher_action_types.ts new file mode 100644 index 0000000000000..78f5d7882e664 --- /dev/null +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/dispatcher_action_types.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +/** + * Action types written by the alerting v2 dispatcher (store_actions_step). + * Tests index alert events into `.rule-events`; the scheduled dispatcher task can + * index these alongside user/API actions. Queries that pick "latest" user action + * must exclude these or flakily observe `unmatched` / `notified` instead of e.g. `activate`. + */ +export const DISPATCHER_SYSTEM_ACTION_TYPES = [ + 'fire', + 'suppress', + 'unmatched', + 'notified', +] as const; diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts index 02d03f386c5cc..a1f1b25202114 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts @@ -6,3 +6,4 @@ */ export { createAlertEvent, indexAlertEvents } from './alert_event'; +export { DISPATCHER_SYSTEM_ACTION_TYPES } from './dispatcher_action_types'; From 73971b39fb23261832af39e0fdf725c3a6d2bdc0 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 13:10:41 +0200 Subject: [PATCH 23/28] Fix types. --- .../actions/acknowledge_action_button.test.tsx | 2 +- .../observability/public/pages/alerts_v2/alerts_v2.tsx | 6 ++++-- .../observability/plugins/observability/tsconfig.json | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx index 07500f482c171..5c81c66733d47 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx @@ -27,7 +27,7 @@ describe('AcknowledgeActionButton', () => { } as any); }); - it('renders Unacknowledge when lastAckAction is undefined (treated as acknowledged)', () => { + it('renders Acknowledge when lastAckAction is undefined (same as not acknowledged)', () => { render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( 'Acknowledge' diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index c8dbdf0c383d4..f2ea2b424a634 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -27,7 +27,7 @@ import { type UnifiedDataTableSettings, } from '@kbn/unified-data-table'; import { css } from '@emotion/react'; -import type { AlertEpisodeStatus } from '@kbn/alerting-v2-plugin/server/resources/datastreams/alert_events'; +import type { AlertEpisodeStatus } from '@kbn/alerting-v2-schemas'; import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query'; import { pagesToDatatableRecords } from '@kbn/alerting-v2-episodes-ui/utils/pages_to_datatable_records'; import { useAlertingRulesIndex } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_index'; @@ -218,7 +218,9 @@ export function AlertsV2Page() { }} externalCustomRenderers={{ 'episode.status': (props) => { - const status = props.row.flattened[props.columnId] as AlertEpisodeStatus; + const status = props.row.flattened[ + props.columnId + ] as unknown as AlertEpisodeStatus; const episodeId = props.row.flattened['episode.id'] as string; const groupHash = props.row.flattened.group_hash as string; diff --git a/x-pack/solutions/observability/plugins/observability/tsconfig.json b/x-pack/solutions/observability/plugins/observability/tsconfig.json index f51366245f4a4..17d43f5197a01 100644 --- a/x-pack/solutions/observability/plugins/observability/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability/tsconfig.json @@ -155,6 +155,7 @@ "@kbn/cell-actions", "@kbn/unified-data-table", "@kbn/alerting-v2-plugin", + "@kbn/alerting-v2-schemas", "@kbn/alerting-v2-episodes-ui", "@kbn/expressions-plugin", ], From 177b8be832cb7951bb9390737cb7ca7cd4d4e5bf Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:35:26 +0000 Subject: [PATCH 24/28] Changes from node scripts/lint_ts_projects --fix --- .../solutions/observability/plugins/observability/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/observability/tsconfig.json b/x-pack/solutions/observability/plugins/observability/tsconfig.json index 17d43f5197a01..699fb01a5e152 100644 --- a/x-pack/solutions/observability/plugins/observability/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability/tsconfig.json @@ -154,7 +154,6 @@ "@kbn/cps", "@kbn/cell-actions", "@kbn/unified-data-table", - "@kbn/alerting-v2-plugin", "@kbn/alerting-v2-schemas", "@kbn/alerting-v2-episodes-ui", "@kbn/expressions-plugin", From d893731ceff71936c30eaf59e5117da8e41f69a5 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:48:07 +0000 Subject: [PATCH 25/28] Changes from node scripts/regenerate_moon_projects.js --update --- x-pack/solutions/observability/plugins/observability/moon.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/observability/moon.yml b/x-pack/solutions/observability/plugins/observability/moon.yml index 8b15c71daac5e..b40017583ce34 100644 --- a/x-pack/solutions/observability/plugins/observability/moon.yml +++ b/x-pack/solutions/observability/plugins/observability/moon.yml @@ -158,7 +158,7 @@ dependsOn: - '@kbn/cps' - '@kbn/cell-actions' - '@kbn/unified-data-table' - - '@kbn/alerting-v2-plugin' + - '@kbn/alerting-v2-schemas' - '@kbn/alerting-v2-episodes-ui' - '@kbn/expressions-plugin' tags: From 58baef0d93e1cf33290b3b3d77a8bc8e8b879990 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 14:51:38 +0200 Subject: [PATCH 26/28] Address PR comments. --- .../acknowledge_action_button.test.tsx | 48 +-- .../actions/acknowledge_action_button.tsx | 24 +- .../alert_episode_actions_cell.test.tsx | 21 +- .../actions/alert_episode_actions_cell.tsx | 2 +- .../actions/alert_episode_tags.tsx | 4 +- ...est.tsx => resolve_action_button.test.tsx} | 25 +- ...n_button.tsx => resolve_action_button.tsx} | 32 +- .../actions/snooze_action_button.test.tsx | 52 +-- .../actions/snooze_action_button.tsx | 51 +-- .../hooks/test_utils.tsx | 28 ++ .../hooks/use_create_alert_action.test.tsx | 104 ++++++ .../hooks/use_create_alert_action.ts | 3 +- ...use_fetch_alerting_episodes_query.test.tsx | 17 +- .../hooks/use_fetch_episode_actions.test.tsx | 44 +-- .../hooks/use_fetch_episode_actions.ts | 41 +- .../hooks/use_fetch_group_actions.test.tsx | 44 +-- .../hooks/use_fetch_group_actions.ts | 15 +- .../queries/build_episode_actions_query.ts | 2 +- .../queries/build_group_actions_query.ts | 13 - .../create_alert_action_route.ts | 73 ---- ...create_alert_action_route_for_type.test.ts | 22 -- .../create_alert_action_route_for_type.ts | 7 +- .../alerting_v2/server/setup/bind_routes.ts | 2 - .../apis/alerting_v2/create_alert_action.ts | 349 ------------------ .../public/pages/alerts_v2/alerts_v2.tsx | 6 +- 25 files changed, 287 insertions(+), 742 deletions(-) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/{deactivate_action_button.test.tsx => resolve_action_button.test.tsx} (72%) rename x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/{deactivate_action_button.tsx => resolve_action_button.tsx} (77%) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/test_utils.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.test.tsx delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route.ts delete mode 100644 x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx index 5c81c66733d47..b28ef03fdbc28 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -15,7 +17,8 @@ import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; jest.mock('../../../hooks/use_create_alert_action'); const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); -const mockServices = { http: {} as any }; + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); describe('AcknowledgeActionButton', () => { const mutate = jest.fn(); @@ -24,53 +27,26 @@ describe('AcknowledgeActionButton', () => { useCreateAlertActionMock.mockReturnValue({ mutate, isLoading: false, - } as any); + } as unknown as ReturnType); }); it('renders Acknowledge when lastAckAction is undefined (same as not acknowledged)', () => { - render(); - expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( - 'Acknowledge' - ); - expect( - screen - .getByTestId('alertEpisodeAcknowledgeActionButton') - .querySelector('[data-euiicon-type="checkCircle"]') - ).toBeInTheDocument(); + render(); + expect(screen.getByText('Acknowledge')).toBeInTheDocument(); }); it('renders Unacknowledge when lastAckAction is ack', () => { render( - - ); - expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( - 'Unacknowledge' + ); - expect( - screen - .getByTestId('alertEpisodeAcknowledgeActionButton') - .querySelector('[data-euiicon-type="crossCircle"]') - ).toBeInTheDocument(); + expect(screen.getByText('Unacknowledge')).toBeInTheDocument(); }); it('renders Acknowledge when lastAckAction is unack', () => { render( - - ); - expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toHaveTextContent( - 'Acknowledge' + ); - expect( - screen - .getByTestId('alertEpisodeAcknowledgeActionButton') - .querySelector('[data-euiicon-type="checkCircle"]') - ).toBeInTheDocument(); + expect(screen.getByText('Acknowledge')).toBeInTheDocument(); }); it('calls ack route mutation on click', async () => { @@ -80,7 +56,7 @@ describe('AcknowledgeActionButton', () => { lastAckAction={ALERT_EPISODE_ACTION_TYPE.UNACK} episodeId="ep-1" groupHash="gh-1" - http={mockServices.http} + http={mockHttp} /> ); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx index 6d46c55685b94..b52ce36da2ef5 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; @@ -39,22 +39,24 @@ export function AcknowledgeActionButton({ defaultMessage: 'Acknowledge', }); + const handleClick = useCallback(() => { + if (!episodeId || !groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType, + body: { episode_id: episodeId }, + }); + }, [createAlertAction, episodeId, groupHash, actionType]); + return ( { - if (!episodeId || !groupHash) { - return; - } - createAlertAction({ - groupHash, - actionType, - body: { episode_id: episodeId }, - }); - }} + onClick={handleClick} isLoading={isLoading} isDisabled={!episodeId || !groupHash} data-test-subj="alertEpisodeAcknowledgeActionButton" diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx index 3edae475d86d0..acf02db8f139a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { AlertEpisodeActionsCell } from './alert_episode_actions_cell'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -15,18 +17,19 @@ import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; jest.mock('../../../hooks/use_create_alert_action'); const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); -const mockServices = { http: {} as any }; + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); describe('AlertEpisodeActionsCell', () => { beforeEach(() => { useCreateAlertActionMock.mockReturnValue({ mutate: jest.fn(), isLoading: false, - } as any); + } as unknown as ReturnType); }); it('renders acknowledge, snooze, and more-actions controls', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toBeInTheDocument(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toBeInTheDocument(); expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument(); @@ -36,7 +39,7 @@ describe('AlertEpisodeActionsCell', () => { const user = userEvent.setup(); render( { /> ); await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); - expect( - await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Resolve'); + expect(screen.getByText('Resolve')).toBeInTheDocument(); }); it('shows Unresolve in popover when group action is deactivated', async () => { const user = userEvent.setup(); render( { /> ); await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); - expect( - await screen.findByTestId('alertingEpisodeActionsResolveActionButton') - ).toHaveTextContent('Unresolve'); + expect(screen.getByText('Unresolve')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx index 31c1ffbc7d302..46af4f25e96c8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_actions_cell.tsx @@ -12,7 +12,7 @@ import type { HttpStart } from '@kbn/core-http-browser'; import { AcknowledgeActionButton } from './acknowledge_action_button'; import { SnoozeActionButton } from './snooze_action_button'; import type { EpisodeAction, GroupAction } from '../../../types/action'; -import { ResolveActionButton } from './deactivate_action_button'; +import { ResolveActionButton } from './resolve_action_button'; export interface AlertEpisodeActionsCellProps { episodeId?: string; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx index 8d1bdcea60a66..8e116020b95f0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/alert_episode_tags.tsx @@ -20,7 +20,7 @@ export function AlertEpisodeTags({ oneLine?: boolean; }) { const [isMoreTagsOpen, setIsMoreTagsOpen] = useState(false); - const onMoreTagsClick = (e: any) => { + const onMoreTagsClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsMoreTagsOpen((isPopoverOpen) => !isPopoverOpen); }; @@ -30,7 +30,7 @@ export function AlertEpisodeTags({ key="more" iconType="tag" onClick={onMoreTagsClick} - onClickAriaLabel={i18n.translate('xpack.observability.component.tags.moreTags.ariaLabel', { + onClickAriaLabel={i18n.translate('xpack.alertingV2.episodesUi.tags.moreTags.ariaLabel', { defaultMessage: 'more tags badge', })} color="hollow" diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.test.tsx similarity index 72% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.test.tsx index d556926d4ffdc..9b7ce83354772 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.test.tsx @@ -8,14 +8,17 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; -import { ResolveActionButton } from './deactivate_action_button'; +import { ResolveActionButton } from './resolve_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; jest.mock('../../../hooks/use_create_alert_action'); const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); -const mockServices = { http: {} as any }; + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); describe('ResolveActionButton', () => { const mutate = jest.fn(); @@ -24,33 +27,27 @@ describe('ResolveActionButton', () => { useCreateAlertActionMock.mockReturnValue({ mutate, isLoading: false, - } as any); + } as unknown as ReturnType); }); it('renders Resolve when active', () => { - render(); - expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( - 'Resolve' - ); + render(); + expect(screen.getByText('Resolve')).toBeInTheDocument(); }); it('renders Unresolve when deactivated', () => { render( ); - expect(screen.getByTestId('alertingEpisodeActionsResolveActionButton')).toHaveTextContent( - 'Unresolve' - ); + expect(screen.getByText('Unresolve')).toBeInTheDocument(); }); it('calls deactivate route mutation on click', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.click(screen.getByTestId('alertingEpisodeActionsResolveActionButton')); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.tsx similarity index 77% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.tsx index d6cd099cc70a5..37edf17d8d31b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/deactivate_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiListGroupItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; @@ -39,25 +39,27 @@ export function ResolveActionButton({ const iconType = isDeactivated ? 'check' : 'cross'; + const handleClick = useCallback(() => { + if (!groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType, + body: { + reason: i18n.translate('xpack.alertingV2.episodesUi.resolveAction.reason', { + defaultMessage: 'Updated from episodes actions UI', + }), + }, + }); + }, [createAlertAction, groupHash, actionType]); + return ( { - if (!groupHash) { - return; - } - createAlertAction({ - groupHash, - actionType, - body: { - reason: i18n.translate('xpack.alertingV2.episodesUi.resolveAction.reason', { - defaultMessage: 'Updated from episodes actions UI', - }), - }, - }); - }} + onClick={handleClick} isDisabled={!groupHash} data-test-subj="alertingEpisodeActionsResolveActionButton" /> diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx index 85b72b7f62706..bef2a87bd2235 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; import { SnoozeActionButton } from './snooze_action_button'; import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; @@ -15,7 +17,8 @@ import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; jest.mock('../../../hooks/use_create_alert_action'); const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); -const mockServices = { http: {} as any }; + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); describe('SnoozeActionButton', () => { const mutate = jest.fn(); @@ -25,54 +28,31 @@ describe('SnoozeActionButton', () => { useCreateAlertActionMock.mockReturnValue({ mutate, isLoading: false, - } as any); + } as unknown as ReturnType); }); it('renders Snooze with bell when not snoozed (after unsnooze)', () => { render( - + ); - expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); - expect( - screen - .getByTestId('alertEpisodeSnoozeActionButton') - .querySelector('[data-euiicon-type="bell"]') - ).toBeInTheDocument(); + expect(screen.getByText('Snooze')).toBeInTheDocument(); }); it('renders Snooze with bell when previous action is undefined', () => { - render(); + render(); expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); - expect( - screen - .getByTestId('alertEpisodeSnoozeActionButton') - .querySelector('[data-euiicon-type="bell"]') - ).toBeInTheDocument(); }); it('renders Unsnooze with bellSlash when snoozed', () => { render( - + ); - expect(screen.getByTestId('alertEpisodeUnsnoozeActionButton')).toHaveTextContent('Unsnooze'); - expect( - screen - .getByTestId('alertEpisodeUnsnoozeActionButton') - .querySelector('[data-euiicon-type="bellSlash"]') - ).toBeInTheDocument(); + expect(screen.getByText('Unsnooze')).toBeInTheDocument(); }); it('opens popover with snooze form content on click', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); @@ -83,9 +63,7 @@ describe('SnoozeActionButton', () => { it('closes popover after clicking Apply', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); await user.click(screen.getByRole('button', { name: 'Apply' })); @@ -101,7 +79,7 @@ describe('SnoozeActionButton', () => { ); @@ -115,9 +93,7 @@ describe('SnoozeActionButton', () => { it('calls snooze route mutation when applying from popover', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); await user.click(screen.getByRole('button', { name: 'Apply' })); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx index 25c7d778ddf00..722f1e8c45c14 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/snooze_action_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiButton, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { HttpStart } from '@kbn/core-http-browser'; @@ -28,21 +28,38 @@ export function SnoozeActionButton({ const closePopover = () => setIsPopoverOpen(false); const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http); + const handleUnsnooze = useCallback(() => { + if (!groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, + }); + }, [createAlertAction, groupHash]); + + const handleApplySnooze = useCallback( + (expiry: string) => { + if (!groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, + body: { expiry }, + }); + setIsPopoverOpen(false); + }, + [createAlertAction, groupHash] + ); + return isSnoozed ? ( { - if (!groupHash) { - return; - } - createAlertAction({ - groupHash, - actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, - }); - }} + onClick={handleUnsnooze} isDisabled={!groupHash} isLoading={isLoading} data-test-subj="alertEpisodeUnsnoozeActionButton" @@ -79,19 +96,7 @@ export function SnoozeActionButton({ panelPaddingSize="m" panelStyle={{ width: 320 }} > - { - if (!groupHash) { - return; - } - createAlertAction({ - groupHash, - actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, - body: { expiry }, - }); - closePopover(); - }} - /> + ); } diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/test_utils.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/test_utils.tsx new file mode 100644 index 0000000000000..6664a3593f02b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/test_utils.tsx @@ -0,0 +1,28 @@ +/* + * 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, { type PropsWithChildren } from 'react'; +import type { QueryClientConfig } from '@kbn/react-query'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; + +export const createTestQueryClient = (config: QueryClientConfig = {}) => { + const { defaultOptions, ...rest } = config; + return new QueryClient({ + ...rest, + defaultOptions: { + ...defaultOptions, + queries: { retry: false, ...defaultOptions?.queries }, + }, + }); +}; + +export const createQueryClientWrapper = (client: QueryClient) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + return Wrapper; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.test.tsx new file mode 100644 index 0000000000000..aaa70190dda08 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas'; +import { ALERTING_V2_ALERT_API_PATH } from '@kbn/alerting-v2-constants'; +import { queryKeys } from '../query_keys'; +import { createQueryClientWrapper, createTestQueryClient } from './test_utils'; +import { useCreateAlertAction } from './use_create_alert_action'; + +const mockHttp = httpServiceMock.createStartContract(); + +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); + +describe('useCreateAlertAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('posts to the action route for the group hash and action type with an empty JSON body by default', async () => { + mockHttp.post.mockResolvedValue({}); + + const { result } = renderHook(() => useCreateAlertAction(mockHttp), { + wrapper, + }); + + await result.current.mutateAsync({ + groupHash: 'group-hash-1', + actionType: ALERT_EPISODE_ACTION_TYPE.ACK, + }); + + expect(mockHttp.post).toHaveBeenCalledWith( + `${ALERTING_V2_ALERT_API_PATH}/group-hash-1/action/_ack`, + { body: JSON.stringify({}) } + ); + }); + + it('stringifies a custom body when provided', async () => { + mockHttp.post.mockResolvedValue({}); + + const { result } = renderHook(() => useCreateAlertAction(mockHttp), { + wrapper, + }); + + const body = { foo: 'bar', n: 1 }; + + await result.current.mutateAsync({ + groupHash: 'gh', + actionType: ALERT_EPISODE_ACTION_TYPE.SNOOZE, + body, + }); + + expect(mockHttp.post).toHaveBeenCalledWith(`${ALERTING_V2_ALERT_API_PATH}/gh/action/_snooze`, { + body: JSON.stringify(body), + }); + }); + + it('invalidates episode and group action queries after a successful mutation', async () => { + mockHttp.post.mockResolvedValue({}); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateAlertAction(mockHttp), { + wrapper, + }); + + await result.current.mutateAsync({ + groupHash: 'gh', + actionType: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE, + }); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2)); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.actionsAll() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.groupActionsAll() }); + }); + + it('does not invalidate queries when the request fails', async () => { + mockHttp.post.mockRejectedValue(new Error('network error')); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateAlertAction(mockHttp), { + wrapper, + }); + + await expect( + result.current.mutateAsync({ + groupHash: 'gh', + actionType: ALERT_EPISODE_ACTION_TYPE.ACK, + }) + ).rejects.toThrow('network error'); + + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts index d9816c7e38478..c80658cdb4514 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -8,10 +8,9 @@ import type { HttpStart } from '@kbn/core-http-browser'; import { useMutation, useQueryClient } from '@kbn/react-query'; import type { AlertEpisodeActionType } from '@kbn/alerting-v2-schemas'; +import { ALERTING_V2_ALERT_API_PATH } from '@kbn/alerting-v2-constants'; import { queryKeys } from '../query_keys'; -const ALERTING_V2_ALERT_API_PATH = '/api/alerting/v2/alerts'; - interface CreateAlertActionParams { groupHash: string; actionType: AlertEpisodeActionType; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx index cb5434e16de09..105592cd1921d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query.test.tsx @@ -6,18 +6,16 @@ */ import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import type { Datatable, ExpressionsStart } from '@kbn/expressions-plugin/public'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { fetchAlertingEpisodes } from '../apis/fetch_alerting_episodes'; import { executeEsqlQuery } from '../utils/execute_esql_query'; import { ALERTING_EPISODES_COUNT_QUERY } from '../constants'; import { useFetchAlertingEpisodesQuery } from './use_fetch_alerting_episodes_query'; -import type { PropsWithChildren } from 'react'; -import React from 'react'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { useAlertingEpisodesDataView } from './use_alerting_episodes_data_view'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { createQueryClientWrapper, createTestQueryClient } from './test_utils'; jest.mock('../apis/fetch_alerting_episodes'); jest.mock('../utils/execute_esql_query'); @@ -47,17 +45,8 @@ const mockEpisodesData = { ], } as unknown as Datatable; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - -const wrapper = ({ children }: PropsWithChildren) => ( - {children} -); +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); describe('useFetchAlertingEpisodesQuery', () => { beforeEach(() => { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx index 3541e47285e98..9f07225d9eb83 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import React, { type PropsWithChildren } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { createQueryClientWrapper, createTestQueryClient } from './test_utils'; import { useFetchEpisodeActions } from './use_fetch_episode_actions'; jest.mock('../utils/execute_esql_query'); @@ -17,17 +16,8 @@ jest.mock('../utils/execute_esql_query'); const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); const mockExpressions = {} as ExpressionsStart; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - -const wrapper = ({ children }: PropsWithChildren) => ( - {children} -); +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); describe('useFetchEpisodeActions', () => { beforeEach(() => { @@ -74,13 +64,8 @@ describe('useFetchEpisodeActions', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); - const call = executeEsqlQueryMock.mock.calls[0][0]; - expect(call.query).toContain('ep-1'); - expect(call.query).toContain('"ack", "unack"'); - expect(call.expressions).toBe(mockExpressions); - const action = result.current.episodeActionsMap.get('ep-1'); - expect(action).toEqual({ + expect(result.current.data?.get('ep-1')).toEqual({ episodeId: 'ep-1', ruleId: 'rule-1', groupHash: 'gh-1', @@ -88,25 +73,6 @@ describe('useFetchEpisodeActions', () => { }); }); - it('escapes quotes in episode ids in the generated query', async () => { - executeEsqlQueryMock.mockResolvedValue({ - rows: [], - } as unknown as Awaited>); - - renderHook( - () => - useFetchEpisodeActions({ - episodeIds: ['say"cheese'], - services: { expressions: mockExpressions }, - }), - { wrapper } - ); - - await waitFor(() => expect(executeEsqlQueryMock).toHaveBeenCalled()); - const query = executeEsqlQueryMock.mock.calls[0][0].query; - expect(query).toContain('\\"'); - }); - it('keeps the last row when duplicate episode ids are returned', async () => { executeEsqlQueryMock.mockResolvedValue({ rows: [ @@ -134,6 +100,6 @@ describe('useFetchEpisodeActions', () => { { wrapper } ); - await waitFor(() => expect(result.current.episodeActionsMap.get('dup')?.ruleId).toBe('r2')); + await waitFor(() => expect(result.current.data?.get('dup')?.ruleId).toBe('r2')); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts index f845c19c77997..45714b9853c9e 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { useMemo } from 'react'; import { useQuery } from '@kbn/react-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { EpisodeAction } from '../types/action'; @@ -18,41 +17,31 @@ export interface UseFetchEpisodeActionsOptions { services: { expressions: ExpressionsStart }; } -export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisodeActionsOptions) => { - const { data, isLoading } = useQuery({ +export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisodeActionsOptions) => + useQuery({ queryKey: queryKeys.actions(episodeIds), queryFn: async ({ signal }) => { const query = buildEpisodeActionsQuery(episodeIds); - const result = await executeEsqlQuery({ + return executeEsqlQuery({ expressions: services.expressions, query, input: null, abortSignal: signal, noCache: true, }); - - return result.rows.map( - (row): EpisodeAction => ({ - episodeId: row.episode_id as string, - ruleId: (row.rule_id as string) ?? null, - groupHash: (row.group_hash as string) ?? null, - lastAckAction: (row.last_ack_action as string) ?? null, - }) - ); }, enabled: episodeIds.length > 0, keepPreviousData: true, - }); - - const episodeActionsMap = useMemo(() => { - const map = new Map(); - if (data) { - for (const action of data) { - map.set(action.episodeId, action); + select: (result) => { + const map = new Map(); + for (const row of result.rows) { + map.set(row.episode_id, { + episodeId: row.episode_id, + ruleId: row.rule_id ?? null, + groupHash: row.group_hash ?? null, + lastAckAction: row.last_ack_action ?? null, + }); } - } - return map; - }, [data]); - - return { episodeActionsMap, isLoading }; -}; + return map; + }, + }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx index e8b6a99a70fb6..23bed81227b7c 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import React, { type PropsWithChildren } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { executeEsqlQuery } from '../utils/execute_esql_query'; +import { createQueryClientWrapper, createTestQueryClient } from './test_utils'; import { useFetchGroupActions } from './use_fetch_group_actions'; jest.mock('../utils/execute_esql_query'); @@ -17,17 +16,8 @@ jest.mock('../utils/execute_esql_query'); const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); const mockExpressions = {} as ExpressionsStart; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - -const wrapper = ({ children }: PropsWithChildren) => ( - {children} -); +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); describe('useFetchGroupActions', () => { beforeEach(() => { @@ -75,14 +65,7 @@ describe('useFetchGroupActions', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(executeEsqlQueryMock).toHaveBeenCalledTimes(1); - const call = executeEsqlQueryMock.mock.calls[0][0]; - expect(call.query).toContain('gh-1'); - expect(call.query).toContain('group_hash'); - expect(call.query).not.toContain('"ack"'); - - const action = result.current.groupActionsMap.get('gh-1'); - expect(action).toEqual({ + expect(result.current.groupActionsMap.get('gh-1')).toEqual({ groupHash: 'gh-1', ruleId: 'rule-1', lastDeactivateAction: 'deactivate', @@ -146,25 +129,6 @@ describe('useFetchGroupActions', () => { expect(result.current.groupActionsMap.get('gh-3')?.tags).toEqual([]); }); - it('escapes quotes in group hashes in the generated query', async () => { - executeEsqlQueryMock.mockResolvedValue({ - rows: [], - } as unknown as Awaited>); - - renderHook( - () => - useFetchGroupActions({ - groupHashes: ['say"cheese'], - services: { expressions: mockExpressions }, - }), - { wrapper } - ); - - await waitFor(() => expect(executeEsqlQueryMock).toHaveBeenCalled()); - const query = executeEsqlQueryMock.mock.calls[0][0].query; - expect(query).toContain('\\"'); - }); - it('keeps the last row when duplicate group hashes are returned', async () => { executeEsqlQueryMock.mockResolvedValue({ rows: [ diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts index ccaa57ee29250..a8919a2414617 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts @@ -11,7 +11,20 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { GroupAction } from '../types/action'; import { executeEsqlQuery } from '../utils/execute_esql_query'; import { queryKeys } from '../query_keys'; -import { buildGroupActionsQuery, tagsFromRow } from '../utils/queries/build_group_actions_query'; +import { buildGroupActionsQuery } from '../utils/queries/build_group_actions_query'; + +const tagsFromRow = (value: unknown): string[] => { + if (value == null) { + return []; + } + if (typeof value === 'string') { + return [value]; + } + if (Array.isArray(value)) { + return value as string[]; + } + return []; +}; export interface UseFetchGroupActionsOptions { groupHashes: string[]; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts index 2e84f29b26161..c8a97150d5748 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_episode_actions_query.ts @@ -16,7 +16,7 @@ export const buildEpisodeActionsQuery = (episodeIds: string[]): string => { | WHERE episode_id IN (${episodeIdLiterals}) | WHERE action_type IN ("ack", "unack") | STATS - last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack") + last_ack_action = LAST(action_type, @timestamp) BY episode_id, rule_id, group_hash | KEEP episode_id, rule_id, group_hash, last_ack_action `.print('basic'); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts index 6f5788e4af91e..221237062fee2 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts @@ -9,19 +9,6 @@ import { esql } from '@elastic/esql'; import { ALERT_ACTIONS_DATA_STREAM } from './constants'; -export const tagsFromRow = (value: unknown): string[] => { - if (value == null) { - return []; - } - if (typeof value === 'string') { - return [value]; - } - if (Array.isArray(value)) { - return value as string[]; - } - return []; -}; - export const buildGroupActionsQuery = (groupHashes: string[]): string => { const groupHashLiterals = groupHashes.map((h) => esql.str(h)); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route.ts deleted file mode 100644 index 4982e5503866c..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 Boom from '@hapi/boom'; -import { - createAlertActionBodySchema, - createAlertActionParamsSchema, - type CreateAlertActionBody, - type CreateAlertActionParams, -} from '@kbn/alerting-v2-schemas'; -import { Request, Response, type RouteHandler } from '@kbn/core-di-server'; -import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; -import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; -import { inject, injectable } from 'inversify'; -import { AlertActionsClient } from '../../lib/alert_actions_client'; -import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; -import { ALERTING_V2_ALERT_API_PATH } from '../constants'; - -@injectable() -export class CreateAlertActionRoute implements RouteHandler { - static method = 'post' as const; - static path = `${ALERTING_V2_ALERT_API_PATH}/{group_hash}/action`; - static security: RouteSecurity = { - authz: { - requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.write], - }, - }; - static options = { - access: 'public', - summary: 'Create an alert action', - description: 'Create an action for a specific alert group.', - tags: ['oas-tag:alerting-v2'], - availability: { stability: 'experimental' }, - } as const; - static validate = { - request: { - params: buildRouteValidationWithZod(createAlertActionParamsSchema), - body: buildRouteValidationWithZod(createAlertActionBodySchema), - }, - } as const; - - constructor( - @inject(Request) - private readonly request: KibanaRequest< - CreateAlertActionParams, - unknown, - CreateAlertActionBody - >, - @inject(Response) private readonly response: KibanaResponseFactory, - @inject(AlertActionsClient) private readonly alertActionsClient: AlertActionsClient - ) {} - - async handle() { - try { - await this.alertActionsClient.createAction({ - groupHash: this.request.params.group_hash, - action: this.request.body, - }); - - return this.response.noContent(); - } catch (e) { - const boom = Boom.isBoom(e) ? e : Boom.boomify(e); - return this.response.customError({ - statusCode: boom.output.statusCode, - body: boom.output.payload, - }); - } - } -} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts index 009e91ad8bc09..f23c2e91d48d7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.test.ts @@ -79,26 +79,4 @@ describe('createAlertActionRouteForType', () => { expect(response.customError).toHaveBeenCalledTimes(1); expect(result).toBe(customErrorResult); }); - - it('applies body mapper when provided', async () => { - const RouteClass = createAlertActionRouteForType({ - actionType: ALERT_EPISODE_ACTION_TYPE.TAG, - pathSuffix: '_tag', - bodySchema: createTagAlertActionBodySchema, - mapBody: (body) => ({ ...body, tags: ['mapped'] }), - }); - const { request, response, alertActionsClient } = buildDeps({ tags: ['p1'] }); - const route = new RouteClass(request as any, response as any, alertActionsClient as any); - - await route.handle(); - - expect(alertActionsClient.createAction).toHaveBeenCalledWith({ - groupHash: 'group-1', - action: { - action_type: 'tag', - tags: ['mapped'], - }, - }); - expect(response.noContent).toHaveBeenCalledTimes(1); - }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts index 1ed6d26920860..8b8766a02f866 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts @@ -28,9 +28,6 @@ interface CreateAlertActionRouteForTypeOptions< bodySchema: z.ZodType< Omit, 'action_type'> >; - mapBody?: ( - body: Omit, 'action_type'> - ) => Omit, 'action_type'>; } export const createAlertActionRouteForType = < @@ -39,7 +36,6 @@ export const createAlertActionRouteForType = < actionType, pathSuffix, bodySchema, - mapBody, }: CreateAlertActionRouteForTypeOptions): RouteDefinition< CreateAlertActionParams, unknown, @@ -80,12 +76,11 @@ export const createAlertActionRouteForType = < async handle() { try { - const mappedBody = mapBody ? mapBody(this.request.body) : this.request.body; await this.alertActionsClient.createAction({ groupHash: this.request.params.group_hash, action: { action_type: actionType, - ...mappedBody, + ...this.request.body, } as Extract, }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index 62ae76c24c468..90ae2bcb8f185 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -16,7 +16,6 @@ import { DeleteRuleRoute } from '../routes/rules/delete_rule_route'; import { BulkDeleteRulesRoute } from '../routes/rules/bulk_delete_rules_route'; import { BulkEnableRulesRoute } from '../routes/rules/bulk_enable_rules_route'; import { BulkDisableRulesRoute } from '../routes/rules/bulk_disable_rules_route'; -import { CreateAlertActionRoute } from '../routes/alert_actions/create_alert_action_route'; import { BulkCreateAlertActionRoute } from '../routes/alert_actions/bulk_create_alert_action_route'; import { CreateAckAlertActionRoute } from '../routes/alert_actions/create_ack_alert_action_route'; import { CreateUnackAlertActionRoute } from '../routes/alert_actions/create_unack_alert_action_route'; @@ -47,7 +46,6 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(BulkDeleteRulesRoute); bind(Route).toConstantValue(BulkEnableRulesRoute); bind(Route).toConstantValue(BulkDisableRulesRoute); - bind(Route).toConstantValue(CreateAlertActionRoute); bind(Route).toConstantValue(CreateAckAlertActionRoute); bind(Route).toConstantValue(CreateUnackAlertActionRoute); bind(Route).toConstantValue(CreateTagAlertActionRoute); diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts deleted file mode 100644 index a451c5681aecc..0000000000000 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts +++ /dev/null @@ -1,349 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; -import type { RoleCredentials } from '../../services'; -import { createAlertEvent, DISPATCHER_SYSTEM_ACTION_TYPES, indexAlertEvents } from './fixtures'; - -const ALERT_ACTION_API_PATH = '/api/alerting/v2/alerts'; -const ALERTING_EVENTS_INDEX = '.rule-events'; -const ALERTING_ACTIONS_INDEX = '.alert-actions'; - -export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { - const samlAuth = getService('samlAuth'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const esClient = getService('es'); - - describe('Create Alert Action API', function () { - this.tags(['skipServerless']); - let roleAuthc: RoleCredentials; - - before(async () => { - roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin'); - }); - - after(async () => { - await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); - await Promise.all([ - esClient.deleteByQuery( - { - index: ALERTING_EVENTS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - conflicts: 'proceed', - }, - { ignore: [404] } - ), - esClient.deleteByQuery( - { - index: ALERTING_ACTIONS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - conflicts: 'proceed', - }, - { ignore: [404] } - ), - ]); - }); - - async function getLatestAction(ruleIds: string[]) { - await esClient.indices.refresh({ index: ALERTING_ACTIONS_INDEX }); - const result = await esClient.search({ - index: ALERTING_ACTIONS_INDEX, - query: { - bool: { - must_not: [{ terms: { action_type: [...DISPATCHER_SYSTEM_ACTION_TYPES] } }], - filter: [{ terms: { rule_id: ruleIds } }], - }, - }, - sort: [{ '@timestamp': 'desc' }], - size: 1, - }); - return result.hits.hits[0]?._source as Record | undefined; - } - - it('should return 204 for ack action and write action document', async () => { - const ruleId = 'ack-test-rule'; - const groupHash = 'ack-test-group'; - const episodeId = 'ack-test-episode'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: episodeId, status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: episodeId }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('ack'); - expect(action!.episode_id).to.be(episodeId); - expect(action!.rule_id).to.be(ruleId); - expect(action!.last_series_event_timestamp).to.be(event['@timestamp']); - }); - - it('should return 204 for unack action and write action document', async () => { - const ruleId = 'unack-test-rule'; - const groupHash = 'unack-test-group'; - const episodeId = 'unack-test-episode'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: episodeId, status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'unack', episode_id: episodeId }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('unack'); - expect(action!.episode_id).to.be(episodeId); - }); - - it('should return 204 for tag action with tags and write action document', async () => { - const ruleId = 'tag-test-rule'; - const groupHash = 'tag-test-group'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: 'tag-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'tag', tags: ['tag1', 'tag2'] }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('tag'); - expect(action!.tags).to.eql(['tag1', 'tag2']); - }); - - it('should return 400 for tag action without tags', async () => { - const groupHash = 'tag-no-tags-test-group'; - const event = createAlertEvent({ - rule: { id: 'tag-no-tags-test-rule', version: 1 }, - group_hash: groupHash, - episode: { id: 'tag-no-tags-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'tag' }); - - expect(response.status).to.be(400); - }); - - it('should return 204 for snooze action and write action document', async () => { - const ruleId = 'snooze-test-rule'; - const groupHash = 'snooze-test-group'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: 'snooze-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'snooze' }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('snooze'); - }); - - it('should return 204 for unsnooze action and write action document', async () => { - const ruleId = 'unsnooze-test-rule'; - const groupHash = 'unsnooze-test-group'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: 'unsnooze-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'unsnooze' }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('unsnooze'); - }); - - it('should return 204 for activate action with reason and write action document', async () => { - const ruleId = 'activate-test-rule'; - const groupHash = 'activate-test-group'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: 'activate-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'activate', reason: 'test reason' }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('activate'); - expect(action!.reason).to.be('test reason'); - }); - - it('should return 400 for activate action without reason', async () => { - const groupHash = 'activate-no-reason-test-group'; - const event = createAlertEvent({ - rule: { id: 'activate-no-reason-test-rule', version: 1 }, - group_hash: groupHash, - episode: { id: 'activate-no-reason-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'activate' }); - - expect(response.status).to.be(400); - }); - - it('should return 204 for deactivate action with reason and write action document', async () => { - const ruleId = 'deactivate-test-rule'; - const groupHash = 'deactivate-test-group'; - const event = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: 'deactivate-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'deactivate', reason: 'test reason' }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('deactivate'); - expect(action!.reason).to.be('test reason'); - }); - - it('should return 400 for deactivate action without reason', async () => { - const groupHash = 'deactivate-no-reason-test-group'; - const event = createAlertEvent({ - rule: { id: 'deactivate-no-reason-test-rule', version: 1 }, - group_hash: groupHash, - episode: { id: 'deactivate-no-reason-test-episode', status: 'active' }, - }); - await indexAlertEvents(esClient, [event]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'deactivate' }); - - expect(response.status).to.be(400); - }); - - it('should return 404 for unknown group hash', async () => { - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/unknown-group-hash/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: 'unknown-episode' }); - - expect(response.status).to.be(404); - }); - - it('should filter by episode_id when provided in request body', async () => { - const ruleId = 'episode-filter-test-rule'; - const groupHash = 'episode-filter-test-group'; - const olderEpisodeId = 'episode-filter-older'; - const newerEpisodeId = 'episode-filter-newer'; - - const olderEvent = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: olderEpisodeId, status: 'active' }, - '@timestamp': '2024-01-01T00:00:00.000Z', - }); - const newerEvent = createAlertEvent({ - rule: { id: ruleId, version: 1 }, - group_hash: groupHash, - episode: { id: newerEpisodeId, status: 'active' }, - '@timestamp': '2024-01-02T00:00:00.000Z', - }); - await indexAlertEvents(esClient, [olderEvent, newerEvent]); - - const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) - .set(roleAuthc.apiKeyHeader) - .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: olderEpisodeId }); - - expect(response.status).to.be(204); - - const action = await getLatestAction([ruleId]); - expect(action).to.be.ok(); - expect(action!.group_hash).to.be(groupHash); - expect(action!.action_type).to.be('ack'); - expect(action!.episode_id).to.be(olderEpisodeId); - expect(action!.last_series_event_timestamp).to.be(olderEvent['@timestamp']); - }); - }); -} diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx index f2ea2b424a634..5585eec5413a1 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alerts_v2/alerts_v2.tsx @@ -133,7 +133,7 @@ export function AlertsV2Page() { [rows] ); - const { episodeActionsMap } = useFetchEpisodeActions({ episodeIds, services }); + const { data: episodeActionsMap } = useFetchEpisodeActions({ episodeIds, services }); const { groupActionsMap } = useFetchGroupActions({ groupHashes, services }); const onSetColumns = useCallback((cols: string[], _hideTimeCol: boolean) => { @@ -227,7 +227,7 @@ export function AlertsV2Page() { return ( ); @@ -240,7 +240,7 @@ export function AlertsV2Page() { From 624620d92c3fffe08fc32b3616298b80d582b974 Mon Sep 17 00:00:00 2001 From: adcoelho Date: Wed, 1 Apr 2026 15:58:24 +0200 Subject: [PATCH 27/28] Remove import of removed test file. --- .../apis/alerting_v2/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/index.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/index.ts index 1839b9b26d787..9b7582ad3f519 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/index.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/index.ts @@ -9,7 +9,6 @@ import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_co export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) { describe('alerting_v2', () => { - loadTestFile(require.resolve('./create_alert_action')); loadTestFile(require.resolve('./bulk_create_alert_action')); loadTestFile(require.resolve('./notification_policy')); loadTestFile(require.resolve('./rule')); From 35a50e7bbb1d903fe906813cda75a580e5b4017b Mon Sep 17 00:00:00 2001 From: Dominique Belcher Date: Wed, 1 Apr 2026 21:57:10 -0400 Subject: [PATCH 28/28] add alert episode actions scout test --- ...bservability_complete.serverless.config.ts | 22 + .../scout_alerting_v2/.meta/api/standard.json | 176 ++++++- .../api/fixtures/constants.ts | 6 +- .../scout_alerting_v2/api/fixtures/index.ts | 8 +- .../api/tests/episode_actions.spec.ts | 447 ++++++++++++++++++ .../api/tests/episode_lifecycle.spec.ts | 8 +- 6 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/serverless/observability_complete.serverless.config.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/serverless/observability_complete.serverless.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/serverless/observability_complete.serverless.config.ts new file mode 100644 index 0000000000000..c2e6b57081f4a --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/alerting_v2/serverless/observability_complete.serverless.config.ts @@ -0,0 +1,22 @@ +/* + * 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 { ScoutServerConfig } from '../../../../../types'; +import { servers as obltServerlessConfig } from '../../default/serverless/observability_complete.serverless.config'; + +export const servers: ScoutServerConfig = { + ...obltServerlessConfig, + kbnTestServer: { + ...obltServerlessConfig.kbnTestServer, + serverArgs: [ + ...obltServerlessConfig.kbnTestServer.serverArgs, + '--xpack.alerting_v2.enabled=true', + ], + }, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/.meta/api/standard.json b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/.meta/api/standard.json index 0d457fddc0fec..e24791e277b59 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/.meta/api/standard.json +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/.meta/api/standard.json @@ -7,7 +7,9 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" ], "location": { "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts", @@ -21,7 +23,9 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" ], "location": { "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts", @@ -35,7 +39,9 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" ], "location": { "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts", @@ -49,13 +55,175 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" ], "location": { "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts", "line": 432, "column": 10 } + }, + { + "id": "episode-actions-ack", + "title": "Episode actions for alert rules should acknowledge an episode", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 199, + "column": 3 + } + }, + { + "id": "episode-actions-unack", + "title": "Episode actions for alert rules should unacknowledge an episode", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 222, + "column": 3 + } + }, + { + "id": "episode-actions-snooze-expiry", + "title": "Episode actions for alert rules should snooze a group with expiry", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 243, + "column": 3 + } + }, + { + "id": "episode-actions-snooze-no-expiry", + "title": "Episode actions for alert rules should snooze a group without expiry", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 268, + "column": 3 + } + }, + { + "id": "episode-actions-unsnooze", + "title": "Episode actions for alert rules should unsnooze a group", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 291, + "column": 3 + } + }, + { + "id": "episode-actions-deactivate", + "title": "Episode actions for alert rules should deactivate a group", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 312, + "column": 3 + } + }, + { + "id": "episode-actions-activate", + "title": "Episode actions for alert rules should activate a group", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 335, + "column": 3 + } + }, + { + "id": "episode-actions-tag", + "title": "Episode actions for alert rules should tag a group", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 358, + "column": 3 + } + }, + { + "id": "episode-actions-unknown-group", + "title": "Episode actions for alert rules should return error for unknown group_hash", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 381, + "column": 3 + } + }, + { + "id": "episode-actions-reject-action-type", + "title": "Episode actions for alert rules should reject body with action_type field", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts", + "line": 395, + "column": 3 + } } ] } \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/constants.ts index 665863aa0436a..812ec22285885 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/constants.ts @@ -9,5 +9,9 @@ export const API_HEADERS = { 'kbn-xsrf': 'true', }; -export { ALERTING_V2_RULE_API_PATH as RULE_API_PATH } from '@kbn/alerting-v2-constants'; +export { + ALERTING_V2_RULE_API_PATH as RULE_API_PATH, + ALERTING_V2_ALERT_API_PATH as ALERT_API_PATH, +} from '@kbn/alerting-v2-constants'; export const ALERTING_EVENTS_INDEX = '.rule-events'; +export const ALERT_ACTIONS_INDEX = '.alert-actions'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/index.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/index.ts index 44ea55b0a6ae5..063cec9d4db96 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/fixtures/index.ts @@ -5,4 +5,10 @@ * 2.0. */ -export { API_HEADERS, RULE_API_PATH, ALERTING_EVENTS_INDEX } from './constants'; +export { + API_HEADERS, + RULE_API_PATH, + ALERTING_EVENTS_INDEX, + ALERT_API_PATH, + ALERT_ACTIONS_INDEX, +} from './constants'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts new file mode 100644 index 0000000000000..3e0d290f04132 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_actions.spec.ts @@ -0,0 +1,447 @@ +/* + * 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 { Client as EsClient } from '@elastic/elasticsearch'; +import { expect } from '@kbn/scout/api'; +import { apiTest, tags } from '@kbn/scout'; +import { + API_HEADERS, + RULE_API_PATH, + ALERT_API_PATH, + ALERTING_EVENTS_INDEX, + ALERT_ACTIONS_INDEX, +} from '../fixtures'; + +const DISPATCHER_SYSTEM_ACTION_TYPES = ['fire', 'suppress', 'unmatched', 'notified'] as const; + +apiTest.describe( + 'Episode actions for alert rules', + { tag: [...tags.stateful.classic, ...tags.serverless.observability.complete] }, + () => { + const SOURCE_INDEX = 'test-alerting-v2-e2e-actions'; + const SCHEDULE_INTERVAL = '5s'; + const LOOKBACK_WINDOW = '1m'; + const POLL_INTERVAL_MS = 1000; + const POLL_TIMEOUT_MS = 60_000; + + const ruleIds: string[] = []; + let groupHashes: string[] = []; + let episodeIds: string[] = []; + + async function waitForEpisodeStatuses( + esClient: EsClient, + ruleId: string, + expectedStatuses: string[] + ): Promise>> { + const start = Date.now(); + + while (Date.now() - start < POLL_TIMEOUT_MS) { + await esClient.indices.refresh({ index: ALERTING_EVENTS_INDEX }).catch(() => {}); + + const result = await esClient.search({ + index: ALERTING_EVENTS_INDEX, + query: { + bool: { + filter: [ + { term: { 'rule.id': ruleId } }, + { term: { type: 'alert' } }, + { exists: { field: 'episode.status' } }, + ], + }, + }, + sort: [{ '@timestamp': 'desc' }], + size: 100, + collapse: { field: 'group_hash' }, + }); + + const stateMap = new Map>(); + for (const hit of result.hits.hits) { + const doc = hit._source as Record; + stateMap.set(doc.group_hash as string, doc); + } + + const statuses = Array.from(stateMap.values()).map( + (doc) => (doc.episode as Record).status as string + ); + + const sortedExpected = [...expectedStatuses].sort(); + const sortedActual = [...statuses].sort(); + if ( + sortedExpected.length === sortedActual.length && + sortedExpected.every((s, i) => s === sortedActual[i]) + ) { + return stateMap; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error( + `Timed out waiting for episode statuses [${expectedStatuses.join(', ')}] for rule ${ruleId}` + ); + } + + async function searchAlertActions( + esClient: EsClient, + groupHash: string, + actionType: string + ): Promise>> { + await esClient.indices.refresh({ index: ALERT_ACTIONS_INDEX }).catch(() => {}); + + const result = await esClient.search({ + index: ALERT_ACTIONS_INDEX, + query: { + bool: { + filter: [ + { term: { group_hash: groupHash } }, + { term: { action_type: actionType } }, + ], + must_not: [ + { + terms: { + action_type: [...DISPATCHER_SYSTEM_ACTION_TYPES], + }, + }, + ], + }, + }, + sort: [{ '@timestamp': 'desc' }], + size: 10, + }); + + return result.hits.hits.map((hit) => hit._source as Record); + } + + apiTest.beforeAll(async ({ esClient, apiClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + + await esClient.indices.create( + { + index: SOURCE_INDEX, + mappings: { + properties: { + '@timestamp': { type: 'date' }, + 'host.name': { type: 'keyword' }, + value: { type: 'long' }, + }, + }, + }, + { ignore: [400] } + ); + + for (const host of ['host-action-a', 'host-action-b']) { + for (let i = 0; i < 3; i++) { + await esClient.index({ + index: SOURCE_INDEX, + document: { + '@timestamp': new Date().toISOString(), + 'host.name': host, + value: i + 1, + }, + refresh: true, + }); + } + } + + const createResponse = await apiClient.post(RULE_API_PATH, { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { + kind: 'alert', + metadata: { name: 'e2e-episode-actions' }, + time_field: '@timestamp', + schedule: { every: SCHEDULE_INTERVAL, lookback: LOOKBACK_WINDOW }, + evaluation: { + query: { + base: `FROM ${SOURCE_INDEX} | WHERE host.name IN ("host-action-a", "host-action-b") | STATS count = COUNT(*) BY host.name`, + condition: 'WHERE count >= 1', + }, + }, + recovery_policy: { type: 'no_breach' }, + grouping: { fields: ['host.name'] }, + state_transition: { pending_count: 0, recovering_count: 0 }, + }, + responseType: 'json', + }); + + expect(createResponse.statusCode).toBe(200); + const ruleId = createResponse.body.id; + ruleIds.push(ruleId); + + const stateMap = await waitForEpisodeStatuses(esClient, ruleId, ['active', 'active']); + + groupHashes = Array.from(stateMap.keys()); + episodeIds = Array.from(stateMap.values()).map( + (doc) => (doc.episode as Record).id as string + ); + }); + + apiTest.afterAll(async ({ esClient, apiClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + + for (const ruleId of ruleIds) { + await apiClient + .delete(`${RULE_API_PATH}/${ruleId}`, { + headers: { ...API_HEADERS, ...apiKeyHeader }, + }) + .catch(() => {}); + } + + await esClient.indices.delete({ index: SOURCE_INDEX }, { ignore: [404] }); + await esClient + .deleteByQuery( + { + index: ALERTING_EVENTS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ) + .catch(() => {}); + await esClient + .deleteByQuery( + { + index: ALERT_ACTIONS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ) + .catch(() => {}); + }); + + apiTest('should acknowledge an episode', async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_ack`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { episode_id: episodeIds[0] }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'ack'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('ack'); + expect(docs[0].episode_id).toBe(episodeIds[0]); + expect(docs[0].actor).toBeDefined(); + expect(docs[0]['@timestamp']).toBeDefined(); + expect(docs[0].rule_id).toBeDefined(); + }); + + apiTest('should unacknowledge an episode', async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_unack`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { episode_id: episodeIds[0] }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'unack'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('unack'); + expect(docs[0].episode_id).toBe(episodeIds[0]); + }); + + apiTest( + 'should snooze a group with expiry', + async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + const expiry = new Date(Date.now() + 3_600_000).toISOString(); + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_snooze`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { expiry }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'snooze'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('snooze'); + expect(docs[0].expiry).toBe(expiry); + } + ); + + apiTest( + 'should snooze a group without expiry', + async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[1]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_snooze`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: {}, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'snooze'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('snooze'); + } + ); + + apiTest('should unsnooze a group', async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_unsnooze`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: {}, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'unsnooze'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('unsnooze'); + }); + + apiTest( + 'should deactivate a group', + async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_deactivate`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { reason: 'Manual resolve from test' }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'deactivate'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('deactivate'); + expect(docs[0].reason).toBe('Manual resolve from test'); + } + ); + + apiTest( + 'should activate a group', + async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_activate`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { reason: 'Manual unresolve from test' }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'activate'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('activate'); + expect(docs[0].reason).toBe('Manual unresolve from test'); + } + ); + + apiTest('should tag a group', async ({ apiClient, esClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[1]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_tag`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { tags: ['critical', 'network', 'production'] }, + responseType: 'text', + } + ); + + expect(response.statusCode).toBe(204); + + const docs = await searchAlertActions(esClient, groupHash, 'tag'); + expect(docs.length >= 1).toBe(true); + expect(docs[0].group_hash).toBe(groupHash); + expect(docs[0].action_type).toBe('tag'); + expect(JSON.stringify(docs[0].tags)).toBe(JSON.stringify(['critical', 'network', 'production'])); + }); + + apiTest( + 'should return error for unknown group_hash', + async ({ apiClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + + const response = await apiClient.post( + `${ALERT_API_PATH}/nonexistent-group-hash/action/_ack`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { episode_id: 'nonexistent-episode' }, + responseType: 'json', + } + ); + + expect(response.statusCode).toBe(404); + } + ); + + apiTest( + 'should reject body with action_type field', + async ({ apiClient, requestAuth }) => { + const { apiKeyHeader } = await requestAuth.getApiKeyForAdmin(); + const groupHash = groupHashes[0]; + + const response = await apiClient.post( + `${ALERT_API_PATH}/${groupHash}/action/_snooze`, + { + headers: { ...API_HEADERS, ...apiKeyHeader }, + body: { action_type: 'snooze' }, + responseType: 'json', + } + ); + + expect(response.statusCode).toBe(400); + } + ); + } +); diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts index de8afd8dbec1f..7a64ba66b00f1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/episode_lifecycle.spec.ts @@ -26,7 +26,10 @@ import { API_HEADERS, RULE_API_PATH, ALERTING_EVENTS_INDEX } from '../fixtures'; * active --[recovered]--> recovering * recovering --[recovered]--> inactive */ -apiTest.describe('Episode lifecycle for alert rules', { tag: tags.stateful.classic }, () => { +apiTest.describe( + 'Episode lifecycle for alert rules', + { tag: [...tags.stateful.classic, ...tags.serverless.observability.complete] }, + () => { const SOURCE_INDEX = 'test-alerting-v2-e2e-source'; const SCHEDULE_INTERVAL = '5s'; const LOOKBACK_WINDOW = '1m'; @@ -490,4 +493,5 @@ apiTest.describe('Episode lifecycle for alert rules', { tag: tags.stateful.class expect(new Set(episodeIds).size).toBe(1); } ); -}); + } +);