From a3c1e6bead44dae506bf7fa6cb10110cb9b1e42f Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Fri, 20 Mar 2026 14:30:44 +0000
Subject: [PATCH] Deduplicate alert incident timeline UI
---
.../v6/internal/subsystems/alerts.md | 7 +
.../subsystems/frontend-primitives.md | 6 +
.../Alerts/IncidentEventFilters.tsx | 63 +++++
.../Alerts/IncidentTimelinePanel.tsx | 124 +++++++++
.../__tests__/IncidentEventFilters.test.tsx | 51 ++++
.../__tests__/IncidentTimelinePanel.test.tsx | 118 +++++++++
.../src/features/alerts/OverviewTab.tsx | 237 ++---------------
frontend-modern/src/features/alerts/types.ts | 5 +-
frontend-modern/src/pages/Alerts.tsx | 247 +++---------------
.../pages/__tests__/Alerts.helpers.test.ts | 25 ++
.../frontendResourceTypeBoundaries.test.ts | 42 +--
11 files changed, 476 insertions(+), 449 deletions(-)
create mode 100644 frontend-modern/src/components/Alerts/IncidentEventFilters.tsx
create mode 100644 frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx
create mode 100644 frontend-modern/src/components/Alerts/__tests__/IncidentEventFilters.test.tsx
create mode 100644 frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index d8cd45d6a..3d6c77050 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -119,6 +119,13 @@ route through `frontend-modern/src/utils/alertIncidentPresentation.ts`
instead of keeping duplicate timeline card structure inline in the alerts
page and overview timelines.
+Expanded alert incident detail now also routes through
+`frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx` and
+`frontend-modern/src/components/Alerts/IncidentEventFilters.tsx` so the
+overview surface and the history table share the same loading/error states,
+canonical timeline meta row, note editor, and event-filter controls instead
+of maintaining two independent incident-detail implementations.
+
Resource incident panel cards, summary rows, and toggle-button presentation
now also route through `frontend-modern/src/utils/alertIncidentPresentation.ts`
instead of remaining inline inside `frontend-modern/src/pages/Alerts.tsx`.
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index af1a085c3..253d24236 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -144,6 +144,12 @@ Alert incident timeline event card structure must also route through
alerts page and overview timeline share one canonical event-card renderer
instead of reimplementing the same summary/detail/output block twice.
+The full expanded alert incident detail panel and event-filter controls must
+also route through `frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx`
+and `frontend-modern/src/components/Alerts/IncidentEventFilters.tsx` rather
+than rebuilding loading/error copy, filter controls, note-editor wiring, or
+event-card composition separately inside the alerts page and overview tab.
+
Resource incident panel card and summary-row presentation must also route
through `frontend-modern/src/utils/alertIncidentPresentation.ts` instead of
maintaining page-local incident panel styling inside
diff --git a/frontend-modern/src/components/Alerts/IncidentEventFilters.tsx b/frontend-modern/src/components/Alerts/IncidentEventFilters.tsx
new file mode 100644
index 000000000..4f7d5971b
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/IncidentEventFilters.tsx
@@ -0,0 +1,63 @@
+import { For, type Accessor } from 'solid-js';
+import { INCIDENT_EVENT_LABELS, INCIDENT_EVENT_TYPES } from '@/features/alerts/types';
+import {
+ getAlertIncidentEventFilterActionButtonClass,
+ getAlertIncidentEventFilterChipClass,
+ getAlertIncidentEventFilterContainerClass,
+ getAlertIncidentEventFilterLabelClass,
+ type AlertIncidentEventFilterVariant,
+} from '@/utils/alertIncidentPresentation';
+
+export interface IncidentEventFiltersProps {
+ filters: Accessor>;
+ setFilters: (next: Set) => void;
+ variant: AlertIncidentEventFilterVariant;
+ showQuickSelection?: boolean;
+}
+
+export function IncidentEventFilters(props: IncidentEventFiltersProps) {
+ const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
+ const next = new Set(props.filters());
+ if (next.has(type)) {
+ next.delete(type);
+ } else {
+ next.add(type);
+ }
+ props.setFilters(next);
+ };
+
+ const label = () => (props.variant === 'panel' ? 'Filter events:' : 'Filters');
+
+ return (
+
+ {label()}
+
+ {(action) => (
+
+ )}
+
+
+ {(type) => {
+ const selected = () => props.filters().has(type);
+ return (
+
+ );
+ }}
+
+
+ );
+}
diff --git a/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx
new file mode 100644
index 000000000..c10b1b119
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx
@@ -0,0 +1,124 @@
+import { Show, For, createMemo, type Accessor } from 'solid-js';
+import type { Incident } from '@/types/api';
+import { filterIncidentEvents } from '@/features/alerts/types';
+import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
+import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
+import {
+ getAlertTimelineEmptyState,
+ getAlertTimelineFailureState,
+ getAlertTimelineFilterEmptyState,
+ getAlertTimelineLoadingState,
+ getAlertTimelineUnavailableState,
+} from '@/utils/alertOverviewPresentation';
+import {
+ type AlertIncidentEventFilterVariant,
+ getAlertIncidentAcknowledgedBadgeClass,
+ getAlertIncidentNoteSaveButtonClass,
+ getAlertIncidentNoteTextareaClass,
+ getAlertIncidentTimelineHeadingClass,
+ getAlertIncidentTimelineMetaRowClass,
+ getAlertResourceIncidentNotePlaceholder,
+ getAlertResourceIncidentSaveNoteLabel,
+} from '@/utils/alertIncidentPresentation';
+
+export interface IncidentTimelinePanelProps {
+ timeline?: Incident | null;
+ loading: boolean;
+ error: boolean;
+ filters: Accessor>;
+ setFilters: (next: Set) => void;
+ filterVariant: AlertIncidentEventFilterVariant;
+ eventCardVariant: 'surface' | 'alt';
+ noteDraft: string;
+ onNoteDraftChange: (value: string) => void;
+ noteSaving: boolean;
+ onSaveNote: () => void;
+ onRetry: () => void;
+}
+
+export function IncidentTimelinePanel(props: IncidentTimelinePanelProps) {
+ const timeline = () => props.timeline;
+ const events = createMemo(() => timeline()?.events || []);
+ const filteredEvents = createMemo(() => filterIncidentEvents(events(), props.filters()));
+
+ return (
+ <>
+
+ {getAlertTimelineLoadingState().text}
+
+
+ {(loadedTimeline) => (
+
+
+ Incident
+ {loadedTimeline().status}
+
+ acknowledged
+
+
+ opened {new Date(loadedTimeline().openedAt).toLocaleString()}
+
+
+ closed {new Date(loadedTimeline().closedAt as string).toLocaleString()}
+
+
+
0}>
+
+
+
0}>
+
+
+ {(event) => (
+
+ )}
+
+
+
+
0 && filteredEvents().length === 0}>
+ {getAlertTimelineFilterEmptyState().text}
+
+
+ {getAlertTimelineEmptyState().text}
+
+
+
+ )}
+
+
+ {getAlertTimelineUnavailableState().text}
}
+ >
+
+
{getAlertTimelineFailureState().text}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend-modern/src/components/Alerts/__tests__/IncidentEventFilters.test.tsx b/frontend-modern/src/components/Alerts/__tests__/IncidentEventFilters.test.tsx
new file mode 100644
index 000000000..48a03b65f
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/__tests__/IncidentEventFilters.test.tsx
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+import { IncidentEventFilters } from '../IncidentEventFilters';
+
+describe('IncidentEventFilters', () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it('renders compact filters with quick selection controls', () => {
+ const [filters, setFilters] = createSignal(new Set(['alert_fired', 'note']));
+
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('Filters')).toBeInTheDocument();
+ expect(screen.getByText('All')).toBeInTheDocument();
+ expect(screen.getByText('None')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('None'));
+ expect(filters().size).toBe(0);
+
+ fireEvent.click(screen.getByText('All'));
+ expect(filters().has('alert_fired')).toBe(true);
+ expect(filters().has('note')).toBe(true);
+ expect(filters().has('runbook')).toBe(true);
+ });
+
+ it('renders panel filters without quick selection controls', () => {
+ const [filters, setFilters] = createSignal(new Set(['alert_fired']));
+
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('Filter events:')).toBeInTheDocument();
+ expect(screen.queryByText('All')).not.toBeInTheDocument();
+ expect(screen.queryByText('None')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Ack'));
+ expect(filters().has('alert_acknowledged')).toBe(true);
+ });
+});
diff --git a/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx b/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx
new file mode 100644
index 000000000..89f17756d
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/__tests__/IncidentTimelinePanel.test.tsx
@@ -0,0 +1,118 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+import { IncidentTimelinePanel } from '../IncidentTimelinePanel';
+import type { Incident } from '@/types/api';
+
+function makeTimeline(overrides: Partial = {}): Incident {
+ return {
+ id: 'incident-1',
+ alertIdentifier: 'alert-1',
+ alertType: 'cpu',
+ level: 'warning',
+ resourceId: 'vm-100',
+ resourceName: 'test-vm',
+ status: 'open',
+ openedAt: '2026-03-20T10:00:00Z',
+ acknowledged: false,
+ events: [
+ {
+ id: 'event-1',
+ type: 'command',
+ timestamp: '2026-03-20T10:05:00Z',
+ summary: 'Command executed',
+ details: {
+ command: 'systemctl status pulse',
+ output_excerpt: 'Active: active (running)',
+ },
+ },
+ ],
+ ...overrides,
+ };
+}
+
+describe('IncidentTimelinePanel', () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it('renders loading, error, and unavailable states through shared copy', () => {
+ const [filters, setFilters] = createSignal(new Set(['command']));
+ const [state, setState] = createSignal<{
+ loading: boolean;
+ error: boolean;
+ timeline?: Incident | null;
+ }>({
+ loading: true,
+ error: false,
+ timeline: undefined,
+ });
+
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('Loading timeline...')).toBeInTheDocument();
+
+ setState({ loading: false, error: true, timeline: undefined });
+
+ expect(screen.getByText('Failed to load timeline.')).toBeInTheDocument();
+
+ setState({ loading: false, error: false, timeline: undefined });
+
+ expect(screen.getByText('No incident timeline available.')).toBeInTheDocument();
+ });
+
+ it('renders shared timeline content and note handling', () => {
+ const [filters, setFilters] = createSignal(new Set(['command']));
+ const handleNoteDraftChange = vi.fn();
+ const handleSave = vi.fn();
+
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('Incident')).toBeInTheDocument();
+ expect(screen.getByText('Command executed')).toBeInTheDocument();
+ expect(screen.getByText('systemctl status pulse')).toBeInTheDocument();
+ expect(screen.getByText('Active: active (running)')).toBeInTheDocument();
+ expect(screen.getByText('All')).toBeInTheDocument();
+ expect(screen.getByText('None')).toBeInTheDocument();
+
+ fireEvent.input(screen.getByPlaceholderText('Add a note for this incident...'), {
+ target: { value: 'updated note' },
+ currentTarget: { value: 'updated note' },
+ });
+ expect(handleNoteDraftChange).toHaveBeenCalledWith('updated note');
+
+ fireEvent.click(screen.getByText('Save Note'));
+ expect(handleSave).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend-modern/src/features/alerts/OverviewTab.tsx b/frontend-modern/src/features/alerts/OverviewTab.tsx
index 14f48bd77..c47a5296d 100644
--- a/frontend-modern/src/features/alerts/OverviewTab.tsx
+++ b/frontend-modern/src/features/alerts/OverviewTab.tsx
@@ -5,13 +5,13 @@ import { useLocation } from '@solidjs/router';
import type { Incident } from '@/types/api';
import { AlertsAPI } from '@/api/alerts';
-import type { Alert, IncidentEvent } from '@/types/api';
+import type { Alert } from '@/types/api';
import type { Override } from './types';
import { alertTypeDisplayLabel } from './helpers';
import { getCanonicalAlertId } from './identity';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
-import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
+import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
@@ -23,89 +23,8 @@ import {
getAlertOverviewPrimaryActionClass,
getAlertOverviewSecondaryActionClass,
getAlertOverviewStartedAtClass,
- getAlertTimelineEmptyState,
- getAlertTimelineFailureState,
- getAlertTimelineFilterEmptyState,
- getAlertTimelineLoadingState,
- getAlertTimelineUnavailableState,
} from '@/utils/alertOverviewPresentation';
-import {
- getAlertIncidentAcknowledgedBadgeClass,
- getAlertIncidentEventFilterChipClass,
- getAlertIncidentEventFilterContainerClass,
- getAlertIncidentEventFilterLabelClass,
- getAlertIncidentNoteSaveButtonClass,
- getAlertIncidentNoteTextareaClass,
- getAlertIncidentTimelineHeadingClass,
- getAlertIncidentTimelineMetaRowClass,
- getAlertResourceIncidentNotePlaceholder,
- getAlertResourceIncidentSaveNoteLabel,
-} from '@/utils/alertIncidentPresentation';
-
-const INCIDENT_EVENT_TYPES = [
- 'alert_fired',
- 'alert_acknowledged',
- 'alert_unacknowledged',
- 'alert_resolved',
- 'ai_analysis',
- 'command',
- 'runbook',
- 'note',
-] as const;
-
-const INCIDENT_EVENT_LABELS: Record<(typeof INCIDENT_EVENT_TYPES)[number], string> = {
- alert_fired: 'Fired',
- alert_acknowledged: 'Ack',
- alert_unacknowledged: 'Unack',
- alert_resolved: 'Resolved',
- ai_analysis: 'Patrol',
- command: 'Cmd',
- runbook: 'Runbook',
- note: 'Note',
-};
-
-function filterIncidentEvents(
- events: IncidentEvent[] | undefined,
- filters: Set,
-): IncidentEvent[] {
- if (!events) return [];
- if (filters.size === 0) return events;
- return events.filter((e) => filters.has(e.type));
-}
-
-function IncidentEventFilters(props: {
- filters: () => Set;
- setFilters: (next: Set) => void;
-}) {
- const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
- const next = new Set(props.filters());
- if (next.has(type)) {
- next.delete(type);
- } else {
- next.add(type);
- }
- props.setFilters(next);
- };
-
- return (
-
- Filter events:
-
- {(type) => {
- const selected = () => props.filters().has(type);
- return (
-
- );
- }}
-
-
- );
-}
+import { INCIDENT_EVENT_TYPES } from './types';
// Overview Tab - Shows current alert status
export function OverviewTab(props: {
@@ -679,133 +598,29 @@ export function OverviewTab(props: {
-
- {getAlertTimelineLoadingState().text}
-
-
-
- {(timeline) => (
-
-
- Incident
- {timeline().status}
-
-
- acknowledged
-
-
-
-
- opened {new Date(timeline().openedAt).toLocaleString()}
-
-
-
-
- closed{' '}
- {new Date(timeline().closedAt as string).toLocaleString()}
-
-
-
- {(() => {
- const events = timeline().events || [];
- const filteredEvents = filterIncidentEvents(
- events,
- incidentEventFilters(),
- );
- return (
- <>
-
0}>
-
-
-
0}>
-
-
- {(event) => (
-
- )}
-
-
-
-
0 && filteredEvents.length === 0}>
-
- {getAlertTimelineFilterEmptyState().text}
-
-
-
-
- {getAlertTimelineEmptyState().text}
-
-
- >
- );
- })()}
-
-
- )}
-
-
-
- {getAlertTimelineUnavailableState().text}
-
- }
- >
-
-
- {getAlertTimelineFailureState().text}
-
-
-
-
-
-
+
+ setIncidentNoteDrafts((prev) => ({
+ ...prev,
+ [getCanonicalAlertId(alert)]: value,
+ }))
+ }
+ noteSaving={incidentNoteSaving().has(getCanonicalAlertId(alert))}
+ onSaveNote={() => {
+ void saveIncidentNote(getCanonicalAlertId(alert), alert.startTime);
+ }}
+ onRetry={() => {
+ void loadIncidentTimeline(getCanonicalAlertId(alert), alert.startTime);
+ }}
+ />
diff --git a/frontend-modern/src/features/alerts/types.ts b/frontend-modern/src/features/alerts/types.ts
index 34328532a..033db1b57 100644
--- a/frontend-modern/src/features/alerts/types.ts
+++ b/frontend-modern/src/features/alerts/types.ts
@@ -220,9 +220,12 @@ export const filterIncidentEvents = (
if (!events || events.length === 0) {
return [];
}
- if (filters.size === 0 || filters.size === INCIDENT_EVENT_TYPES.length) {
+ if (filters.size === INCIDENT_EVENT_TYPES.length) {
return events;
}
+ if (filters.size === 0) {
+ return [];
+ }
return events.filter((event) => filters.has(event.type));
};
diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx
index 3021371d4..d7dbd528c 100644
--- a/frontend-modern/src/pages/Alerts.tsx
+++ b/frontend-modern/src/pages/Alerts.tsx
@@ -34,6 +34,8 @@ import { SearchInput } from '@/components/shared/SearchInput';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
+import { IncidentEventFilters } from '@/components/Alerts/IncidentEventFilters';
+import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
@@ -64,17 +66,9 @@ import type { EmailConfig, AppriseConfig } from '@/api/notifications';
import { pbsInstanceFromResource, pmgInstanceFromResource } from '@/utils/resourceStateAdapters';
import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget';
import { getActionableAgentIdFromResource, hasAgentFacet } from '@/utils/agentResources';
-import { IncidentTimelineEventCard } from '@/components/Alerts/IncidentTimelineEventCard';
import {
getAlertHistoryStatusPresentation,
- getAlertIncidentAcknowledgedBadgeClass,
- getAlertIncidentEventFilterActionButtonClass,
- getAlertIncidentEventFilterChipClass,
- getAlertIncidentEventFilterContainerClass,
- getAlertIncidentEventFilterLabelClass,
getAlertIncidentLevelBadgeClass,
- getAlertIncidentNoteSaveButtonClass,
- getAlertIncidentNoteTextareaClass,
getAlertIncidentTimelineHeadingClass,
getAlertIncidentTimelineMetaRowClass,
getAlertIncidentTimelineOutputClass,
@@ -89,10 +83,8 @@ import {
getAlertResourceIncidentLoadingState,
getAlertResourceIncidentNoteSaveFailure,
getAlertResourceIncidentNoteSavedLabel,
- getAlertResourceIncidentNotePlaceholder,
getAlertResourceIncidentPanelTitle,
getAlertResourceIncidentRefreshLabel,
- getAlertResourceIncidentSaveNoteLabel,
getAlertResourceIncidentSummaryRowClass,
getAlertResourceIncidentTimelineFailure,
getAlertResourceIncidentToggleLabel,
@@ -197,11 +189,6 @@ import {
getAlertHistoryLoadingState,
getAlertHistoryEmptyState,
getAlertHistorySearchPlaceholder,
- getAlertTimelineEmptyState,
- getAlertTimelineFailureState,
- getAlertTimelineFilterEmptyState,
- getAlertTimelineLoadingState,
- getAlertTimelineUnavailableState,
} from '@/utils/alertOverviewPresentation';
import {
ALERT_CONFIG_COOLDOWN_DESCRIPTION,
@@ -285,7 +272,6 @@ import {
type EscalationNotifyTarget,
INCIDENT_EVENT_TYPES,
GROUPING_WINDOW_DEFAULT_SECONDS,
- INCIDENT_EVENT_LABELS,
summarizeIncidentEvents,
fallbackCooldownMinutes,
clampCooldownMinutes,
@@ -3830,55 +3816,6 @@ function ScheduleTab(props: ScheduleTabProps) {
);
}
-export function IncidentEventFilters(props: {
- filters: () => Set;
- setFilters: (next: Set) => void;
-}) {
- const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => {
- const next = new Set(props.filters());
- if (next.has(type)) {
- next.delete(type);
- } else {
- next.add(type);
- }
- props.setFilters(next);
- };
-
- return (
-
- Filters
-
-
-
- {(type) => {
- const selected = () => props.filters().has(type);
- return (
-
- );
- }}
-
-
- );
-}
-
// History Tab - Comprehensive alert table
function HistoryTab(props: {
hasAIAlertsFeature: () => boolean;
@@ -4984,6 +4921,8 @@ function HistoryTab(props: {
@@ -5369,157 +5308,33 @@ function HistoryTab(props: {
>
-
-
- {getAlertTimelineLoadingState().text}
-
-
-
-
- {(timeline) => (
-
-
-
- Incident
-
- {timeline().status}
-
-
- acknowledged
-
-
-
-
- opened{' '}
- {new Date(
- timeline().openedAt,
- ).toLocaleString()}
-
-
-
-
- closed{' '}
- {new Date(
- timeline().closedAt as string,
- ).toLocaleString()}
-
-
-
- {(() => {
- const events = timeline().events || [];
- const filteredEvents = filterIncidentEvents(
- events,
- historyIncidentEventFilters(),
- );
- return (
- <>
-
0}>
-
-
-
0}>
-
-
- {(event) => (
-
- )}
-
-
-
-
0 &&
- filteredEvents.length === 0
- }
- >
-
- {getAlertTimelineFilterEmptyState().text}
-
-
-
-
- {getAlertTimelineEmptyState().text}
-
-
- >
- );
- })()}
-
-
- )}
-
-
-
- {getAlertTimelineUnavailableState().text}
-
- }
- >
-
-
- {getAlertTimelineFailureState().text}
-
-
-
-
-
-
+
+ setIncidentNoteDrafts((prev) => ({
+ ...prev,
+ [rowKey]: value,
+ }))
+ }
+ noteSaving={incidentNoteSaving().has(rowKey)}
+ onSaveNote={() => {
+ void saveIncidentNote(rowKey, alert.id, alert.startTime);
+ }}
+ onRetry={() => {
+ void loadIncidentTimeline(
+ rowKey,
+ alert.id,
+ alert.startTime,
+ );
+ }}
+ />
diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts
index 321dde8d6..8665725be 100644
--- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts
+++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
ALERT_TAB_SEGMENTS,
+ filterIncidentEvents,
pathForTab,
summarizeIncidentEvents,
tabFromPath,
@@ -292,6 +293,30 @@ describe('incident timeline presentation helpers', () => {
});
describe('incident event summaries', () => {
+ it('treats a fully selected filter set as all events and an empty set as no events', () => {
+ const events = [
+ { id: '1', type: 'alert_fired', timestamp: '2026-03-20T10:00:00Z', summary: 'Fired' },
+ { id: '2', type: 'note', timestamp: '2026-03-20T10:01:00Z', summary: 'Noted' },
+ ];
+
+ expect(
+ filterIncidentEvents(
+ events,
+ new Set([
+ 'alert_fired',
+ 'alert_acknowledged',
+ 'alert_unacknowledged',
+ 'alert_resolved',
+ 'ai_analysis',
+ 'command',
+ 'runbook',
+ 'note',
+ ]),
+ ),
+ ).toEqual(events);
+ expect(filterIncidentEvents(events, new Set())).toEqual([]);
+ });
+
it('summarizes incident events in canonical order and retains unknown event types', () => {
expect(
summarizeIncidentEvents([
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index 12e354305..3d7adb481 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -140,6 +140,8 @@ import useStorageAlertStateSource from '@/components/Storage/useStorageAlertStat
import temperatureUtilSource from '@/utils/temperature.ts?raw';
import pmgInstanceDrawerSource from '@/components/PMG/PMGInstanceDrawer.tsx?raw';
import serviceHealthPresentationSource from '@/utils/serviceHealthPresentation.ts?raw';
+import alertIncidentEventFiltersSource from '@/components/Alerts/IncidentEventFilters.tsx?raw';
+import alertIncidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
import alertIncidentTimelineEventCardSource from '@/components/Alerts/IncidentTimelineEventCard.tsx?raw';
import swarmServicesDrawerSource from '@/components/Docker/SwarmServicesDrawer.tsx?raw';
import swarmPresentationSource from '@/utils/swarmPresentation.ts?raw';
@@ -2207,14 +2209,8 @@ describe('frontend resource type boundaries', () => {
expect(alertsPageSource).toContain('getAlertResourceIncidentAcknowledgedByLabel');
expect(alertsPageSource).toContain('getAlertResourceIncidentToggleLabel');
expect(alertsPageSource).toContain('getAlertResourceIncidentFilteredEventsEmptyState');
- expect(alertsPageSource).toContain('getAlertResourceIncidentNotePlaceholder');
- expect(alertsPageSource).toContain('getAlertResourceIncidentSaveNoteLabel');
- expect(alertsPageSource).toContain('getAlertIncidentEventFilterContainerClass');
- expect(alertsPageSource).toContain('getAlertIncidentEventFilterActionButtonClass');
- expect(alertsPageSource).toContain('getAlertIncidentEventFilterChipClass');
- expect(alertsPageSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
- expect(alertsPageSource).toContain('getAlertIncidentNoteTextareaClass');
- expect(alertsPageSource).toContain('getAlertIncidentNoteSaveButtonClass');
+ expect(alertsPageSource).toContain('IncidentEventFilters');
+ expect(alertsPageSource).toContain('IncidentTimelinePanel');
expect(alertsPageSource).toContain('getAlertIncidentTimelineMetaRowClass');
expect(alertsPageSource).toContain('getAlertIncidentTimelineHeadingClass');
expect(alertsPageSource).toContain('IncidentTimelineEventCard');
@@ -2854,19 +2850,21 @@ describe('frontend resource type boundaries', () => {
});
it('keeps alert incident timeline state copy in a shared presentation utility', () => {
- expect(alertOverviewTabSource).toContain('getAlertTimelineLoadingState');
- expect(alertOverviewTabSource).toContain('getAlertTimelineFilterEmptyState');
- expect(alertOverviewTabSource).toContain('getAlertTimelineEmptyState');
- expect(alertOverviewTabSource).toContain('getAlertTimelineUnavailableState');
- expect(alertOverviewTabSource).toContain('getAlertTimelineFailureState');
- expect(alertOverviewTabSource).toContain('getAlertIncidentEventFilterContainerClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentEventFilterChipClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentNoteTextareaClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentNoteSaveButtonClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentTimelineMetaRowClass');
- expect(alertOverviewTabSource).toContain('getAlertIncidentTimelineHeadingClass');
- expect(alertOverviewTabSource).toContain('IncidentTimelineEventCard');
+ expect(alertOverviewTabSource).toContain('IncidentTimelinePanel');
+ expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterContainerClass');
+ expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterChipClass');
+ expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterActionButtonClass');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineLoadingState');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineFilterEmptyState');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineEmptyState');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineUnavailableState');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertTimelineFailureState');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentAcknowledgedBadgeClass');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentNoteTextareaClass');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentNoteSaveButtonClass');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentTimelineMetaRowClass');
+ expect(alertIncidentTimelinePanelSource).toContain('getAlertIncidentTimelineHeadingClass');
+ expect(alertIncidentTimelinePanelSource).toContain('IncidentTimelineEventCard');
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineEventCardClass');
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineDetailClass');
expect(alertOverviewTabSource).not.toContain('getAlertIncidentTimelineCommandClass');
@@ -2967,6 +2965,8 @@ describe('frontend resource type boundaries', () => {
expect(alertIncidentTimelineEventCardSource).toContain(
'export function IncidentTimelineEventCard',
);
+ expect(alertIncidentEventFiltersSource).toContain('export function IncidentEventFilters');
+ expect(alertIncidentTimelinePanelSource).toContain('export function IncidentTimelinePanel');
expect(alertIncidentTimelineEventCardSource).toContain(
'getAlertIncidentTimelineEventCardClass',
);