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/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..b28ef03fdbc28 --- /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,71 @@ +/* + * 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 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'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); + +describe('AcknowledgeActionButton', () => { + const mutate = jest.fn(); + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as unknown as ReturnType); + }); + + it('renders Acknowledge when lastAckAction is undefined (same as not acknowledged)', () => { + render(); + expect(screen.getByText('Acknowledge')).toBeInTheDocument(); + }); + + it('renders Unacknowledge when lastAckAction is ack', () => { + render( + + ); + expect(screen.getByText('Unacknowledge')).toBeInTheDocument(); + }); + + it('renders Acknowledge when lastAckAction is unack', () => { + render( + + ); + expect(screen.getByText('Acknowledge')).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: 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 new file mode 100644 index 0000000000000..b52ce36da2ef5 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/acknowledge_action_button.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useCallback } 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 { + lastAckAction?: string | null; + episodeId?: string; + groupHash?: string | null; + http: HttpStart; +} + +export function AcknowledgeActionButton({ + lastAckAction, + episodeId, + groupHash, + http, +}: AcknowledgeActionButtonProps) { + const isAcknowledged = lastAckAction === ALERT_EPISODE_ACTION_TYPE.ACK; + const actionType = isAcknowledged + ? ALERT_EPISODE_ACTION_TYPE.UNACK + : ALERT_EPISODE_ACTION_TYPE.ACK; + const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http); + + const label = isAcknowledged + ? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', { + defaultMessage: 'Unacknowledge', + }) + : i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.acknowledge', { + defaultMessage: 'Acknowledge', + }); + + const handleClick = useCallback(() => { + if (!episodeId || !groupHash) { + return; + } + createAlertAction({ + groupHash, + actionType, + body: { episode_id: episodeId }, + }); + }, [createAlertAction, episodeId, groupHash, actionType]); + + return ( + + {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 new file mode 100644 index 0000000000000..acf02db8f139a --- /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,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 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'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); + +describe('AlertEpisodeActionsCell', () => { + beforeEach(() => { + useCreateAlertActionMock.mockReturnValue({ + mutate: jest.fn(), + isLoading: false, + } as unknown as ReturnType); + }); + + 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 Resolve when not deactivated', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton')); + 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(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 new file mode 100644 index 0000000000000..46af4f25e96c8 --- /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,97 @@ +/* + * 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 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 './resolve_action_button'; + +export interface AlertEpisodeActionsCellProps { + episodeId?: string; + groupHash?: string; + episodeAction?: EpisodeAction; + groupAction?: GroupAction; + http: HttpStart; +} + +export function AlertEpisodeActionsCell({ + episodeId, + groupHash, + episodeAction, + groupAction, + http, +}: 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_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..e80eeb7c86bc0 --- /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,36 @@ +/* + * 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); + }); +}); 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..a628dd180b76f --- /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,166 @@ +/* + * 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'; + +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(); +}; + +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_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', + }, +]; + +export const AlertEpisodeSnoozeForm = ({ + onApplySnooze, +}: { + onApplySnooze: (expiry: string) => void; +}) => { + 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} + + ))} + +
+ ); +}; 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..93018f7540012 --- /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,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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(await screen.findByText('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 new file mode 100644 index 0000000000000..8e116020b95f0 --- /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: React.MouseEvent) => { + 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/alert_episodes/actions/resolve_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 new file mode 100644 index 0000000000000..9b7ce83354772 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 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 './resolve_action_button'; +import { useCreateAlertAction } from '../../../hooks/use_create_alert_action'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); + +describe('ResolveActionButton', () => { + const mutate = jest.fn(); + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as unknown as ReturnType); + }); + + it('renders Resolve when active', () => { + render(); + expect(screen.getByText('Resolve')).toBeInTheDocument(); + }); + + it('renders Unresolve when deactivated', () => { + render( + + ); + expect(screen.getByText('Unresolve')).toBeInTheDocument(); + }); + + 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: 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/resolve_action_button.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.tsx new file mode 100644 index 0000000000000..37edf17d8d31b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/alert_episodes/actions/resolve_action_button.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useCallback } 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 { + lastDeactivateAction?: string | null; + groupHash?: string | null; + http: HttpStart; +} + +export function ResolveActionButton({ + lastDeactivateAction, + groupHash, + http, +}: ResolveActionButtonProps) { + const isDeactivated = lastDeactivateAction === ALERT_EPISODE_ACTION_TYPE.DEACTIVATE; + const actionType = isDeactivated + ? ALERT_EPISODE_ACTION_TYPE.ACTIVATE + : ALERT_EPISODE_ACTION_TYPE.DEACTIVATE; + const { mutate: createAlertAction } = useCreateAlertAction(http); + + const label = isDeactivated + ? i18n.translate('xpack.alertingV2.episodesUi.resolveAction.activate', { + defaultMessage: 'Unresolve', + }) + : i18n.translate('xpack.alertingV2.episodesUi.resolveAction.deactivate', { + defaultMessage: 'Resolve', + }); + + 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 ( + + ); +} 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..bef2a87bd2235 --- /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,107 @@ +/* + * 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, 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'; + +jest.mock('../../../hooks/use_create_alert_action'); + +const useCreateAlertActionMock = jest.mocked(useCreateAlertAction); + +const mockHttp: HttpStart = httpServiceMock.createStartContract(); + +describe('SnoozeActionButton', () => { + const mutate = jest.fn(); + + beforeEach(() => { + mutate.mockReset(); + useCreateAlertActionMock.mockReturnValue({ + mutate, + isLoading: false, + } as unknown as ReturnType); + }); + + it('renders Snooze with bell when not snoozed (after unsnooze)', () => { + render( + + ); + expect(screen.getByText('Snooze')).toBeInTheDocument(); + }); + + it('renders Snooze with bell when previous action is undefined', () => { + render(); + expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toHaveTextContent('Snooze'); + }); + + it('renders Unsnooze with bellSlash when snoozed', () => { + render( + + ); + expect(screen.getByText('Unsnooze')).toBeInTheDocument(); + }); + + it('opens popover with snooze form content on click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('alertEpisodeSnoozeActionButton')); + + expect(await screen.findByTestId('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('calls unsnooze mutation when Unsnooze is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('alertEpisodeUnsnoozeActionButton')); + + expect(mutate).toHaveBeenCalledWith({ + groupHash: 'gh-1', + actionType: ALERT_EPISODE_ACTION_TYPE.UNSNOOZE, + }); + }); + + 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: 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 new file mode 100644 index 0000000000000..722f1e8c45c14 --- /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,102 @@ +/* + * 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, { useCallback, 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'; + +export function SnoozeActionButton({ + lastSnoozeAction, + groupHash, + http, +}: { + lastSnoozeAction?: string | null; + groupHash?: string | null; + http: HttpStart; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const isSnoozed = lastSnoozeAction === ALERT_EPISODE_ACTION_TYPE.SNOOZE; + const togglePopover = () => setIsPopoverOpen((prev) => !prev); + 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 ? ( + + {i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.unsnooze', { + defaultMessage: 'Unsnooze', + })} + + ) : ( + + {i18n.translate('xpack.alertingV2.episodesUi.snoozeAction.snooze', { + defaultMessage: 'Snooze', + })} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="downLeft" + panelPaddingSize="m" + panelStyle={{ width: 320 }} + > + + + ); +} 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 58% 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..86dd69287babf 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 @@ -6,42 +6,43 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { AlertingEpisodeStatusBadge } from './alerting_episode_status_badge'; +import { render, screen } from '@testing-library/react'; +import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; +import { ALERT_EPISODE_STATUS } from '@kbn/alerting-v2-schemas'; -describe('AlertingEpisodeStatusBadge', () => { +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/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 66% 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 10f3d84a816ef..aa78c404dcc2b 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 @@ -8,46 +8,46 @@ 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 AlertingEpisodeStatusBadgeProps { +export interface AlertEpisodeStatusBadgeProps { status: AlertEpisodeStatus; } /** * Renders a badge indicating the status of an alerting episode. */ -export const AlertingEpisodeStatusBadge = ({ status }: AlertingEpisodeStatusBadgeProps) => { - if (status === 'inactive') { +export const AlertEpisodeStatusBadge = ({ status }: AlertEpisodeStatusBadgeProps) => { + if (status === ALERT_EPISODE_STATUS.INACTIVE) { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.inactiveStatusBadgeLabel', { defaultMessage: 'Inactive', })} ); } - if (status === 'pending') { + if (status === ALERT_EPISODE_STATUS.PENDING) { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.pendingStatusBadgeLabel', { defaultMessage: 'Pending', })} ); } - if (status === 'active') { + if (status === ALERT_EPISODE_STATUS.ACTIVE) { return ( - + {i18n.translate('xpack.alertingV2EpisodesUi.activeStatusBadgeLabel', { defaultMessage: 'Active', })} ); } - if (status === 'recovering') { + if (status === ALERT_EPISODE_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..2e67194c964f1 --- /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,131 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { AlertEpisodeStatusCell } from './alert_episode_status_cell'; +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(); + 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 bellSlash badge when group action has snooze', () => { + renderWithI18n( + + ); + 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 via episodeAction', () => { + renderWithI18n( + + ); + expect(screen.getByTestId('alertEpisodeStatusCellAckIndicator')).toBeInTheDocument(); + }); + + it('shows acknowledged tooltip on hover', async () => { + const user = userEvent.setup(); + renderWithI18n( + + ); + await user.hover(screen.getByTestId('alertEpisodeStatusCellAckIndicator')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(/acknowledged/i); + }); + + it('renders inactive badge when group action has deactivate', () => { + renderWithI18n( + + ); + 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 new file mode 100644 index 0000000000000..cb592b266fa9e --- /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,111 @@ +/* + * 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, EuiToolTip } from '@elastic/eui'; +import { FormattedDate, FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { + ALERT_EPISODE_ACTION_TYPE, + ALERT_EPISODE_STATUS, + type AlertEpisodeStatus, +} from '@kbn/alerting-v2-schemas'; +import type { EpisodeAction, GroupAction } from '../../../types/action'; +import { AlertEpisodeStatusBadge } from './alert_episode_status_badge'; + +export interface AlertEpisodeStatusCellProps { + status: AlertEpisodeStatus; + episodeAction?: EpisodeAction; + groupAction?: GroupAction; +} + +export function AlertEpisodeStatusCell({ + status, + episodeAction, + groupAction, +}: AlertEpisodeStatusCellProps) { + const isAcknowledged = episodeAction?.lastAckAction === ALERT_EPISODE_ACTION_TYPE.ACK; + const isSnoozed = groupAction?.lastSnoozeAction === ALERT_EPISODE_ACTION_TYPE.SNOOZE; + + return ( + + + + + {isSnoozed && ( + + + ), + }} + /> + ) : ( + + ) + } + > + + + + )} + {isAcknowledged && ( + + + } + > + + + + )} + + ); +} 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 new file mode 100644 index 0000000000000..c80658cdb4514 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_create_alert_action.ts @@ -0,0 +1,36 @@ +/* + * 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 type { AlertEpisodeActionType } from '@kbn/alerting-v2-schemas'; +import { ALERTING_V2_ALERT_API_PATH } from '@kbn/alerting-v2-constants'; +import { queryKeys } from '../query_keys'; + +interface CreateAlertActionParams { + groupHash: string; + actionType: AlertEpisodeActionType; + body?: Record; +} + +export const useCreateAlertAction = (http: HttpStart) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ groupHash, actionType, body = {} }: CreateAlertActionParams) => { + await http.post(`${ALERTING_V2_ALERT_API_PATH}/${groupHash}/action/_${actionType}`, { + body: JSON.stringify(body), + }); + }, + onSuccess: async () => { + 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_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 575f1dfa41da0..b863c84554127 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,16 +6,14 @@ */ 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 { 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'); @@ -43,17 +41,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 new file mode 100644 index 0000000000000..9f07225d9eb83 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 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'); + +const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); +const mockExpressions = {} as ExpressionsStart; + +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); + +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 episodeActionsMap keyed by episode id', async () => { + executeEsqlQueryMock.mockResolvedValue({ + rows: [ + { + episode_id: 'ep-1', + rule_id: 'rule-1', + group_hash: 'gh-1', + last_ack_action: 'ack', + }, + ], + } 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); + + expect(result.current.data?.get('ep-1')).toEqual({ + episodeId: 'ep-1', + ruleId: 'rule-1', + groupHash: 'gh-1', + lastAckAction: 'ack', + }); + }); + + 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', + }, + { + episode_id: 'dup', + rule_id: 'r2', + group_hash: null, + last_ack_action: 'unack', + }, + ], + } as unknown as Awaited>); + + const { result } = renderHook( + () => + useFetchEpisodeActions({ + episodeIds: ['dup'], + services: { expressions: mockExpressions }, + }), + { wrapper } + ); + + 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 new file mode 100644 index 0000000000000..45714b9853c9e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_episode_actions.ts @@ -0,0 +1,47 @@ +/* + * 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 { useQuery } from '@kbn/react-query'; +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'; +import { buildEpisodeActionsQuery } from '../utils/queries/build_episode_actions_query'; + +export interface UseFetchEpisodeActionsOptions { + episodeIds: string[]; + services: { expressions: ExpressionsStart }; +} + +export const useFetchEpisodeActions = ({ episodeIds, services }: UseFetchEpisodeActionsOptions) => + useQuery({ + queryKey: queryKeys.actions(episodeIds), + queryFn: async ({ signal }) => { + const query = buildEpisodeActionsQuery(episodeIds); + return executeEsqlQuery({ + expressions: services.expressions, + query, + input: null, + abortSignal: signal, + noCache: true, + }); + }, + enabled: episodeIds.length > 0, + keepPreviousData: true, + 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; + }, + }); 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..23bed81227b7c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.test.tsx @@ -0,0 +1,165 @@ +/* + * 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 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'); + +const executeEsqlQueryMock = jest.mocked(executeEsqlQuery); +const mockExpressions = {} as ExpressionsStart; + +const queryClient = createTestQueryClient(); +const wrapper = createQueryClientWrapper(queryClient); + +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(result.current.groupActionsMap.get('gh-1')).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('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..a8919a2414617 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_fetch_group_actions.ts @@ -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 { 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'; +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[]; + 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/moon.yml b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/moon.yml index e40def681892f..44da77cd9e991 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/rna-project-team' 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' - '@kbn/es-query' - '@kbn/react-hooks' - '@kbn/alerting-v2-constants' 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 8a3c43fd37344..9eb8c8641719e 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 @@ -9,6 +9,11 @@ import type { EpisodesFilterState, EpisodesSortState } from './utils/build_episo export const queryKeys = { all: ['alert-episodes'] 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, list: ( pageSize: number, filterState?: EpisodesFilterState, 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 ee60f95854f59..bfcb14a6a6f90 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", @@ -27,6 +28,7 @@ "@kbn/react-query", "@kbn/data-plugin", "@kbn/discover-utils", + "@kbn/i18n-react", "@kbn/es-query", "@kbn/react-hooks", "@kbn/alerting-v2-constants", diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.ts new file mode 100644 index 0000000000000..797ded94c7192 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/types/action.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EpisodeAction { + episodeId: string; + 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; + tags: string[]; +} 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 517d3d4f178af..e93b1f44a9187 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' timeField='@timestamp'", - null + null, + undefined ); expect(result).toEqual(mockDatatable); }); @@ -74,7 +75,8 @@ describe('executeEsqlQuery', () => { expect(mockExpressionsService.execute).toHaveBeenCalledWith( "esql 'FROM logs' timeField='@timestamp'", - input + input, + undefined ); }); @@ -100,7 +102,8 @@ describe('executeEsqlQuery', () => { expect(mockExpressionsService.execute).toHaveBeenCalledWith( "esql 'FROM index | WHERE status == \\'active\\'' timeField='@timestamp'", - 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 c2770f98a140d..aa227f36919e7 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; } /** Time field used for the time range filter (must match the expression's timeField argument). */ @@ -28,9 +30,11 @@ export const executeEsqlQuery = ({ query, input, abortSignal, + noCache, }: ExecuteEsqlQueryOptions) => { const expression = `esql '${query.replace(/'/g, "\\'")}' timeField='${ESQL_TIME_FIELD}'`; - const executionContract = expressions.execute(expression, input); + const options = noCache ? { allowCache: false } : undefined; + const executionContract = expressions.execute(expression, input, options); abortSignal?.addEventListener('abort', (e) => { executionContract.cancel((e.target as AbortSignal)?.reason); }); 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..c8a97150d5748 --- /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) + 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..221237062fee2 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/utils/queries/build_group_actions_query.ts @@ -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 { esql } from '@elastic/esql'; + +import { ALERT_ACTIONS_DATA_STREAM } from './constants'; + +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 fbf6d42b88292..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,39 +7,99 @@ 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', + TAG = 'tag', + SNOOZE = 'snooze', + UNSNOOZE = 'unsnooze', + ACTIVATE = 'activate', + DEACTIVATE = 'deactivate', +} + +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.'), }); +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, @@ -81,40 +141,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..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,14 +7,14 @@ 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'; import { AlertActionsClient } from './alert_actions_client'; import { getBulkAlertEventsESQLResponse, - getBulkGetAlertActionsESQLResponse, getAlertEventESQLResponse, getEmptyESQLResponse, } from './fixtures/query_responses'; @@ -40,7 +40,7 @@ describe('AlertActionsClient', () => { describe('createAction', () => { const actionData: CreateAlertActionBody = { - action_type: 'ack', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, episode_id: 'episode-1', }; @@ -61,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', @@ -87,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', }; @@ -109,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'], }; @@ -127,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', @@ -159,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( @@ -182,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( @@ -202,152 +214,24 @@ 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 }, - ]; - - queryServiceEsClient.esql.query.mockResolvedValueOnce(getBulkAlertEventsESQLResponse([])); - - const result = await client.createBulkActions(actions); - - expect(result).toEqual({ processed: 0, total: 2 }); - 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([ + const actions: BulkCreateAlertActionItemBody[] = [ { + group_hash: 'unknown-1', + action_type: ALERT_EPISODE_ACTION_TYPE.ACK, 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, + group_hash: 'unknown-2', + action_type: ALERT_EPISODE_ACTION_TYPE.SNOOZE, }, - ]); - }); - - 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'], - }, - ]) - ); + queryServiceEsClient.esql.query.mockResolvedValueOnce(getBulkAlertEventsESQLResponse([])); - const result = await client.bulkGet(['episode-1', 'episode-2']); + const result = await client.createBulkActions(actions); - 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'], - }, - ]); + expect(result).toEqual({ processed: 0, total: 2 }); + expect(storageServiceEsClient.bulk).not.toHaveBeenCalled(); }); }); }); 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 837d38156e840..7b9078fd55030 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 { @@ -26,7 +25,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 { @@ -48,40 +46,13 @@ export class AlertActionsClient { }), ]); - await this.storageService.bulkIndexDocs({ - index: ALERT_ACTIONS_DATA_STREAM, - docs: [ - this.buildAlertActionDocument({ - action: params.action, - alertEvent, - userProfileUid, - }), - ], - }); - } - - 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; + await this.bulkIndexActions([ + this.buildAlertActionDocument({ + action: params.action, + alertEvent, + userProfileUid, + }), + ]); } public async createBulkActions( @@ -116,12 +87,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/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 410d3acb76fbe..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/datastreams/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/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 }); 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 8fe660780cba8..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/bulk_get_alert_actions_route.ts +++ /dev/null @@ -1,63 +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 { ALERTING_V2_ALERT_API_PATH } from '../constants'; - -@injectable() -export class BulkGetAlertActionsRoute implements RouteHandler { - static method = 'post' as const; - static path = `${ALERTING_V2_ALERT_API_PATH}/action/_bulk_get`; - static security: RouteSecurity = { - authz: { - requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.read], - }, - }; - static options = { - access: 'public', - summary: 'Bulk get alert actions', - description: 'Get actions for multiple episodes in a single request.', - tags: ['oas-tag:alerting-v2'], - availability: { stability: 'experimental' }, - } 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/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..0bf76edd491dc --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.test.ts @@ -0,0 +1,70 @@ +/* + * 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('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..878af3fc8f66b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_ack_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createAckAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateAckAlertActionRoute = createAlertActionRouteForType({ + 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.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..b130610e72085 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.test.ts @@ -0,0 +1,71 @@ +/* + * 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('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..f36b258e7d20e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_activate_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createActivateAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateActivateAlertActionRoute = createAlertActionRouteForType({ + 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.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 new file mode 100644 index 0000000000000..f23c2e91d48d7 --- /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,82 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + 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 suffix = '_tag'; + const RouteClass = createAlertActionRouteForType({ + actionType: ALERT_EPISODE_ACTION_TYPE.TAG, + pathSuffix: suffix, + bodySchema: createTagAlertActionBodySchema, + }); + + expect(RouteClass.method).toBe('post'); + 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: ALERT_EPISODE_ACTION_TYPE.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: ALERT_EPISODE_ACTION_TYPE.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); + }); +}); 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..8b8766a02f866 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_alert_action_route_for_type.ts @@ -0,0 +1,99 @@ +/* + * 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 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'; +import type { z } from '@kbn/zod/v4'; +import { AlertActionsClient } from '../../lib/alert_actions_client'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { ALERTING_V2_ALERT_API_PATH } from '../constants'; + +interface CreateAlertActionRouteForTypeOptions< + TAction extends CreateAlertActionBody['action_type'] +> { + actionType: TAction; + pathSuffix: string; + bodySchema: z.ZodType< + Omit, 'action_type'> + >; +} + +export const createAlertActionRouteForType = < + TAction extends CreateAlertActionBody['action_type'] +>({ + actionType, + pathSuffix, + bodySchema, +}: CreateAlertActionRouteForTypeOptions): RouteDefinition< + CreateAlertActionParams, + unknown, + Omit, 'action_type'>, + 'post' +> => { + type ActionBody = Omit, 'action_type'>; + + @injectable() + class CreateTypedAlertActionRoute implements RouteHandler { + static method = 'post' as const; + 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: '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), + 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 { + await this.alertActionsClient.createAction({ + groupHash: this.request.params.group_hash, + action: { + action_type: actionType, + ...this.request.body, + } 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..d6a539b7104b2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.test.ts @@ -0,0 +1,71 @@ +/* + * 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('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..ba15e7a697743 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_deactivate_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createDeactivateAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateDeactivateAlertActionRoute = createAlertActionRouteForType({ + 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.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..73250ce0891ab --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.test.ts @@ -0,0 +1,96 @@ +/* + * 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('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..cdba68e22c088 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_snooze_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createSnoozeAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateSnoozeAlertActionRoute = createAlertActionRouteForType({ + 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.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..c8931b52b8d6c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.test.ts @@ -0,0 +1,70 @@ +/* + * 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('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..7cad9b63c2cd2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_tag_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createTagAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateTagAlertActionRoute = createAlertActionRouteForType({ + 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.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..58d148e21f55d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.test.ts @@ -0,0 +1,71 @@ +/* + * 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('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..4355ccf23dcc2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unack_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createUnackAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateUnackAlertActionRoute = createAlertActionRouteForType({ + 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.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..491ebaf2c6a86 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.test.ts @@ -0,0 +1,70 @@ +/* + * 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('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..add4589d88768 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/alert_actions/create_unsnooze_alert_action_route.ts @@ -0,0 +1,18 @@ +/* + * 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 { + ALERT_EPISODE_ACTION_TYPE, + createUnsnoozeAlertActionBodySchema, +} from '@kbn/alerting-v2-schemas'; +import { createAlertActionRouteForType } from './create_alert_action_route_for_type'; + +export const CreateUnsnoozeAlertActionRoute = createAlertActionRouteForType({ + actionType: ALERT_EPISODE_ACTION_TYPE.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 74ff69efd97a8..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,9 +16,14 @@ 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 { BulkGetAlertActionsRoute } from '../routes/alert_actions/bulk_get_alert_actions_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,9 +46,14 @@ 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); + bind(Route).toConstantValue(CreateSnoozeAlertActionRoute); + bind(Route).toConstantValue(CreateUnsnoozeAlertActionRoute); + bind(Route).toConstantValue(CreateActivateAlertActionRoute); + bind(Route).toConstantValue(CreateDeactivateAlertActionRoute); bind(Route).toConstantValue(BulkCreateAlertActionRoute); - bind(Route).toConstantValue(BulkGetAlertActionsRoute); bind(Route).toConstantValue(CreateNotificationPolicyRoute); bind(Route).toConstantValue(GetNotificationPolicyRoute); bind(Route).toConstantValue(UpdateNotificationPolicyRoute); 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); } ); -}); + } +); 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 deleted file mode 100644 index bdeb59d332e93..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, 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: ['fire', 'suppress'] } }], - 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/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'; 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')); diff --git a/x-pack/solutions/observability/plugins/observability/moon.yml b/x-pack/solutions/observability/plugins/observability/moon.yml index fedb21a3584dc..8e4365502993d 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' - '@kbn/ingest-hub-plugin' 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 0c8a3a4ddcc3d..e158de62eb97c 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 { map } from 'rxjs'; import { EuiCode, @@ -28,13 +29,17 @@ 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 { 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'; import type { EpisodesFilterState, EpisodesSortState, } from '@kbn/alerting-v2-episodes-ui/utils/build_episodes_esql_query'; -import { useFetchAlertingEpisodesQuery } from '@kbn/alerting-v2-episodes-ui/hooks/use_fetch_alerting_episodes_query'; -import { AlertingEpisodeStatusBadge } from '@kbn/alerting-v2-episodes-ui/components/alerting_episode_status_badge'; import { useAlertingRulesCache } from '@kbn/alerting-v2-episodes-ui/hooks/use_alerting_rules_cache'; import useObservable from 'react-use/lib/useObservable'; import type { InputTimeRange } from '@kbn/data-plugin/public/query'; @@ -51,10 +56,37 @@ const DEFAULT_SORT: EpisodesSortState = { sortField: '@timestamp', sortDirection const ALERTS_V2_TABLE_SETTINGS: UnifiedDataTableSettings = { columns: { duration: { width: 100 }, - 'episode.status': { width: 128 }, + 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 <>; } @@ -78,10 +110,12 @@ export function AlertsV2Page() { const [filterState, setFilterState] = useState({}); const [sortState, setSortState] = useState(DEFAULT_SORT); const [columns, setColumns] = useState([ + 'episode.status', '@timestamp', 'rule.id', - 'episode.status', 'duration', + 'tags', + 'actions', ]); const [rowHeight, setRowHeight] = useState(2); @@ -158,6 +192,22 @@ export function AlertsV2Page() { [episodesData?.rows] ); + const episodeIds = useMemo( + () => rows?.map((row) => row.flattened['episode.id'] as string).filter(Boolean), + [rows] + ); + + const groupHashes = useMemo( + () => [...new Set(rows?.map((row) => row.flattened.group_hash as string).filter(Boolean))], + [rows] + ); + + const { data: episodeActionsMap } = useFetchEpisodeActions({ + episodeIds: episodeIds ?? [], + services, + }); + const { groupActionsMap } = useFetchGroupActions({ groupHashes, services }); + const onSetColumns = useCallback((cols: string[], _hideTimeCol: boolean) => { setColumns(cols); }, []); @@ -225,16 +275,7 @@ export function AlertsV2Page() { ({ + ...column, + displayAsText: i18n.translate('xpack.observability.alertsV2.columns.actions', { + defaultMessage: 'Actions', + }), + }), + 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 ; + 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; + + return ( + + ); + }, + actions: (props) => { + const episodeId = props.row.flattened['episode.id'] as string; + const groupHash = props.row.flattened.group_hash as string; + + return ( + + ); + }, + tags: (props) => { + const groupHash = props.row.flattened.group_hash as string; + const groupAction = groupActionsMap.get(groupHash); + + return ; }, 'rule.id': (props) => { if (!Object.keys(rulesCache).length && isLoadingRules) { diff --git a/x-pack/solutions/observability/plugins/observability/tsconfig.json b/x-pack/solutions/observability/plugins/observability/tsconfig.json index a8dab55cbdd8c..837d0d9dc5c1f 100644 --- a/x-pack/solutions/observability/plugins/observability/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability/tsconfig.json @@ -154,7 +154,7 @@ "@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", "@kbn/ingest-hub-plugin",