Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cfbe35b
Add actions' visualization to episode table:
adcoelho Mar 23, 2026
2088e18
Remove bulk get alert actions.
adcoelho Mar 25, 2026
483575b
Implements small UI fixes.
adcoelho Mar 25, 2026
c9c9e6d
Add unit tests.
adcoelho Mar 25, 2026
f1fcb01
Address PR comment. Rename deactivate to resolve in the UI.
adcoelho Mar 26, 2026
38357d4
Fix file path to use snake_case.
adcoelho Mar 26, 2026
9af9e6a
Add snooze popover to episodes table.
adcoelho Mar 26, 2026
cf1b96f
Create per action type routes for alerting v2.
adcoelho Mar 26, 2026
fefa77e
Use action type constant in the episodes-ui.
adcoelho Mar 30, 2026
3c4a7b1
Changes from node scripts/lint_ts_projects --fix
kibanamachine Mar 30, 2026
f061998
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Mar 30, 2026
25cc2da
Add small PR fixes.
adcoelho Mar 30, 2026
280cd5e
Fix type check error with routes.
adcoelho Mar 30, 2026
486863b
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Mar 30, 2026
9d64b59
Update snooze button UI.
adcoelho Mar 30, 2026
bb9c735
Display snooze expiry date over the bell icon.
adcoelho Mar 30, 2026
cf5410f
Ensure the episodes table reflects the action changes immediately.
adcoelho Mar 30, 2026
6516e49
Split episode and group actions in the episodes table.
adcoelho Mar 30, 2026
3edf707
Merge remote-tracking branch 'upstream/main' into alerting-v2-episode…
adcoelho Apr 1, 2026
208b28e
Address PR comments.
adcoelho Apr 1, 2026
c64e731
Change new apis to public.
adcoelho Apr 1, 2026
e28a580
Fix type errors.
adcoelho Apr 1, 2026
c8f69bb
Fix flaky action tests.
adcoelho Apr 1, 2026
73971b3
Fix types.
adcoelho Apr 1, 2026
177b8be
Changes from node scripts/lint_ts_projects --fix
kibanamachine Apr 1, 2026
d893731
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Apr 1, 2026
58baef0
Address PR comments.
adcoelho Apr 1, 2026
624620d
Remove import of removed test file.
adcoelho Apr 1, 2026
2b630e1
Merge remote-tracking branch 'upstream/main' into alerting-v2-episode…
adcoelho Apr 1, 2026
35a50e7
add alert episode actions scout test
dominiqueclarke Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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',
],
},
};
Original file line number Diff line number Diff line change
@@ -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<typeof useCreateAlertAction>);
});

it('renders Acknowledge when lastAckAction is undefined (same as not acknowledged)', () => {
render(<AcknowledgeActionButton http={mockHttp} />);
expect(screen.getByText('Acknowledge')).toBeInTheDocument();
});

it('renders Unacknowledge when lastAckAction is ack', () => {
render(
<AcknowledgeActionButton lastAckAction={ALERT_EPISODE_ACTION_TYPE.ACK} http={mockHttp} />
);
expect(screen.getByText('Unacknowledge')).toBeInTheDocument();
});

it('renders Acknowledge when lastAckAction is unack', () => {
render(
<AcknowledgeActionButton lastAckAction={ALERT_EPISODE_ACTION_TYPE.UNACK} http={mockHttp} />
);
expect(screen.getByText('Acknowledge')).toBeInTheDocument();
});

it('calls ack route mutation on click', async () => {
const user = userEvent.setup();
render(
<AcknowledgeActionButton
lastAckAction={ALERT_EPISODE_ACTION_TYPE.UNACK}
episodeId="ep-1"
groupHash="gh-1"
http={mockHttp}
/>
);

await user.click(screen.getByTestId('alertEpisodeAcknowledgeActionButton'));

expect(mutate).toHaveBeenCalledWith({
groupHash: 'gh-1',
actionType: ALERT_EPISODE_ACTION_TYPE.ACK,
body: { episode_id: 'ep-1' },
});
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<EuiButton
size="s"
color="text"
fill={false}
iconType={isAcknowledged ? 'crossCircle' : 'checkCircle'}
onClick={handleClick}
isLoading={isLoading}
isDisabled={!episodeId || !groupHash}
data-test-subj="alertEpisodeAcknowledgeActionButton"
>
{label}
</EuiButton>
);
}
Original file line number Diff line number Diff line change
@@ -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<typeof useCreateAlertAction>);
});

it('renders acknowledge, snooze, and more-actions controls', () => {
render(<AlertEpisodeActionsCell http={mockHttp} />);
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(
<AlertEpisodeActionsCell
http={mockHttp}
groupAction={{
groupHash: 'g1',
ruleId: 'r1',
lastDeactivateAction: null,
lastSnoozeAction: null,
snoozeExpiry: null,
tags: [],
}}
/>
);
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(
<AlertEpisodeActionsCell
http={mockHttp}
groupAction={{
groupHash: 'g1',
ruleId: 'r1',
lastDeactivateAction: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE,
lastSnoozeAction: null,
snoozeExpiry: null,
tags: [],
}}
/>
);
await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton'));
expect(screen.getByText('Unresolve')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<EuiFlexGroup
gutterSize="xs"
wrap
responsive={true}
alignItems="center"
justifyContent="flexEnd"
>
<EuiFlexItem grow={false}>
<AcknowledgeActionButton
lastAckAction={episodeAction?.lastAckAction}
episodeId={episodeId}
groupHash={groupHash}
http={http}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SnoozeActionButton
lastSnoozeAction={groupAction?.lastSnoozeAction}
groupHash={groupHash}
http={http}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
aria-label={i18n.translate(
'xpack.alertingV2.episodesUi.actionsCell.moreActionsAriaLabel',
{
defaultMessage: 'More actions',
}
)}
button={
<EuiButtonIcon
display="empty"
color="text"
size="xs"
iconType="boxesHorizontal"
aria-label={i18n.translate(
'xpack.alertingV2.episodesUi.actionsCell.moreActionsAriaLabel',
{
defaultMessage: 'More actions',
}
)}
onClick={() => setIsMoreOpen((open) => !open)}
data-test-subj="alertingEpisodeActionsMoreButton"
/>
}
isOpen={isMoreOpen}
closePopover={() => setIsMoreOpen(false)}
anchorPosition="downLeft"
panelPaddingSize="s"
>
<EuiListGroup gutterSize="none" bordered={false} flush={true} size="l">
<ResolveActionButton
lastDeactivateAction={groupAction?.lastDeactivateAction}
groupHash={groupHash}
http={http}
/>
</EuiListGroup>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -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(<AlertEpisodeSnoozeForm onApplySnooze={onApplySnooze} />);

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);
});
});
Loading
Loading