Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
Comment thread
adcoelho marked this conversation as resolved.
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', {
Comment thread
adcoelho marked this conversation as resolved.
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}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should hide the button instead by returning null. Wdyt? Not sure under which scenarios the episodeId or the groupHash can be undefined. If we are waiting for queries to return data, maybe the parent component can handle that, and this component always assumes that the episodeId or the groupHash is defined.

data-test-subj="alertEpisodeAcknowledgeActionButton"
>
{label}
</EuiButton>
);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it misses some tests like opening the popover, etc.

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;
Comment thread
adcoelho marked this conversation as resolved.
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