Deduplicate alert incident timeline UI

This commit is contained in:
rcourtman
2026-03-20 14:30:44 +00:00
parent d6ca24d252
commit a3c1e6bead
11 changed files with 476 additions and 449 deletions
@@ -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`.
@@ -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
@@ -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<Set<string>>;
setFilters: (next: Set<string>) => 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 (
<div class={getAlertIncidentEventFilterContainerClass(props.variant)}>
<span class={getAlertIncidentEventFilterLabelClass(props.variant)}>{label()}</span>
<For each={props.showQuickSelection ? ['All', 'None'] : []}>
{(action) => (
<button
type="button"
class={getAlertIncidentEventFilterActionButtonClass()}
onClick={() =>
props.setFilters(action === 'All' ? new Set(INCIDENT_EVENT_TYPES) : new Set())
}
>
{action}
</button>
)}
</For>
<For each={INCIDENT_EVENT_TYPES}>
{(type) => {
const selected = () => props.filters().has(type);
return (
<button
type="button"
class={getAlertIncidentEventFilterChipClass(selected(), props.variant)}
onClick={() => toggleFilter(type)}
>
{INCIDENT_EVENT_LABELS[type]}
</button>
);
}}
</For>
</div>
);
}
@@ -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<Set<string>>;
setFilters: (next: Set<string>) => 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 (
<>
<Show when={props.loading}>
<p class="text-xs text-muted">{getAlertTimelineLoadingState().text}</p>
</Show>
<Show when={!props.loading && timeline()}>
{(loadedTimeline) => (
<div class="space-y-3">
<div class={getAlertIncidentTimelineMetaRowClass()}>
<span class={getAlertIncidentTimelineHeadingClass()}>Incident</span>
<span>{loadedTimeline().status}</span>
<Show when={loadedTimeline().acknowledged}>
<span class={getAlertIncidentAcknowledgedBadgeClass()}>acknowledged</span>
</Show>
<Show when={loadedTimeline().openedAt}>
<span>opened {new Date(loadedTimeline().openedAt).toLocaleString()}</span>
</Show>
<Show when={loadedTimeline().closedAt}>
<span>closed {new Date(loadedTimeline().closedAt as string).toLocaleString()}</span>
</Show>
</div>
<Show when={events().length > 0}>
<IncidentEventFilters
filters={props.filters}
setFilters={props.setFilters}
variant={props.filterVariant}
showQuickSelection={props.filterVariant === 'compact'}
/>
</Show>
<Show when={filteredEvents().length > 0}>
<div class="space-y-2">
<For each={filteredEvents()}>
{(event) => (
<IncidentTimelineEventCard event={event} variant={props.eventCardVariant} />
)}
</For>
</div>
</Show>
<Show when={events().length > 0 && filteredEvents().length === 0}>
<p class="text-xs text-muted">{getAlertTimelineFilterEmptyState().text}</p>
</Show>
<Show when={events().length === 0}>
<p class="text-xs text-muted">{getAlertTimelineEmptyState().text}</p>
</Show>
<div class="flex flex-col gap-2">
<textarea
class={getAlertIncidentNoteTextareaClass()}
rows={2}
placeholder={getAlertResourceIncidentNotePlaceholder()}
value={props.noteDraft}
onInput={(event) => props.onNoteDraftChange(event.currentTarget.value)}
/>
<div class="flex justify-end">
<button
class={getAlertIncidentNoteSaveButtonClass()}
disabled={props.noteSaving || !props.noteDraft.trim()}
onClick={() => props.onSaveNote()}
>
{getAlertResourceIncidentSaveNoteLabel(props.noteSaving)}
</button>
</div>
</div>
</div>
)}
</Show>
<Show when={!props.loading && !timeline()}>
<Show
when={props.error}
fallback={<p class="text-xs text-muted">{getAlertTimelineUnavailableState().text}</p>}
>
<div class="flex items-center gap-2">
<p class="text-xs text-error">{getAlertTimelineFailureState().text}</p>
<button class="text-xs text-primary hover:underline" onClick={() => props.onRetry()}>
{getAlertTimelineFailureState().actionLabel}
</button>
</div>
</Show>
</Show>
</>
);
}
@@ -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(() => (
<IncidentEventFilters
filters={filters}
setFilters={setFilters}
variant="compact"
showQuickSelection
/>
));
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(() => (
<IncidentEventFilters filters={filters} setFilters={setFilters} variant="panel" />
));
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);
});
});
@@ -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> = {}): 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(() => (
<IncidentTimelinePanel
loading={state().loading}
error={state().error}
timeline={state().timeline}
filters={filters}
setFilters={setFilters}
filterVariant="panel"
eventCardVariant="alt"
noteDraft=""
onNoteDraftChange={vi.fn()}
noteSaving={false}
onSaveNote={vi.fn()}
onRetry={vi.fn()}
/>
));
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(() => (
<IncidentTimelinePanel
loading={false}
error={false}
timeline={makeTimeline()}
filters={filters}
setFilters={setFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft="operator note"
onNoteDraftChange={handleNoteDraftChange}
noteSaving={false}
onSaveNote={handleSave}
onRetry={vi.fn()}
/>
));
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);
});
});
@@ -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<string>,
): IncidentEvent[] {
if (!events) return [];
if (filters.size === 0) return events;
return events.filter((e) => filters.has(e.type));
}
function IncidentEventFilters(props: {
filters: () => Set<string>;
setFilters: (next: Set<string>) => 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 (
<div class={getAlertIncidentEventFilterContainerClass('panel')}>
<span class={getAlertIncidentEventFilterLabelClass('panel')}>Filter events:</span>
<For each={INCIDENT_EVENT_TYPES}>
{(type) => {
const selected = () => props.filters().has(type);
return (
<button
onClick={() => toggleFilter(type)}
class={getAlertIncidentEventFilterChipClass(selected(), 'panel')}
>
{INCIDENT_EVENT_LABELS[type]}
</button>
);
}}
</For>
</div>
);
}
import { INCIDENT_EVENT_TYPES } from './types';
// Overview Tab - Shows current alert status
export function OverviewTab(props: {
@@ -679,133 +598,29 @@ export function OverviewTab(props: {
</div>
<Show when={expandedIncidents().has(getCanonicalAlertId(alert))}>
<div class="mt-3 border-t border-border pt-3">
<Show when={incidentLoading()[getCanonicalAlertId(alert)]}>
<p class="text-xs text-muted">{getAlertTimelineLoadingState().text}</p>
</Show>
<Show when={!incidentLoading()[getCanonicalAlertId(alert)]}>
<Show when={incidentTimelines()[getCanonicalAlertId(alert)]}>
{(timeline) => (
<div class="space-y-3">
<div class={getAlertIncidentTimelineMetaRowClass()}>
<span class={getAlertIncidentTimelineHeadingClass()}>Incident</span>
<span>{timeline().status}</span>
<Show when={timeline().acknowledged}>
<span class={getAlertIncidentAcknowledgedBadgeClass()}>
acknowledged
</span>
</Show>
<Show when={timeline().openedAt}>
<span>
opened {new Date(timeline().openedAt).toLocaleString()}
</span>
</Show>
<Show when={timeline().closedAt}>
<span>
closed{' '}
{new Date(timeline().closedAt as string).toLocaleString()}
</span>
</Show>
</div>
{(() => {
const events = timeline().events || [];
const filteredEvents = filterIncidentEvents(
events,
incidentEventFilters(),
);
return (
<>
<Show when={events.length > 0}>
<IncidentEventFilters
filters={incidentEventFilters}
setFilters={setIncidentEventFilters}
/>
</Show>
<Show when={filteredEvents.length > 0}>
<div class="space-y-2">
<For each={filteredEvents}>
{(event) => (
<IncidentTimelineEventCard event={event} variant="alt" />
)}
</For>
</div>
</Show>
<Show when={events.length > 0 && filteredEvents.length === 0}>
<p class="text-xs text-muted">
{getAlertTimelineFilterEmptyState().text}
</p>
</Show>
<Show when={events.length === 0}>
<p class="text-xs text-muted">
{getAlertTimelineEmptyState().text}
</p>
</Show>
</>
);
})()}
<div class="flex flex-col gap-2">
<textarea
class={getAlertIncidentNoteTextareaClass()}
rows={2}
placeholder={getAlertResourceIncidentNotePlaceholder()}
value={incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''}
onInput={(e) => {
const value = e.currentTarget.value;
setIncidentNoteDrafts((prev) => ({
...prev,
[getCanonicalAlertId(alert)]: value,
}));
}}
/>
<div class="flex justify-end">
<button
class={getAlertIncidentNoteSaveButtonClass()}
disabled={
incidentNoteSaving().has(getCanonicalAlertId(alert)) ||
!(
incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''
).trim()
}
onClick={() => {
void saveIncidentNote(
getCanonicalAlertId(alert),
alert.startTime,
);
}}
>
{getAlertResourceIncidentSaveNoteLabel(
incidentNoteSaving().has(getCanonicalAlertId(alert)),
)}
</button>
</div>
</div>
</div>
)}
</Show>
<Show when={!incidentTimelines()[getCanonicalAlertId(alert)]}>
<Show
when={incidentErrors()[getCanonicalAlertId(alert)]}
fallback={
<p class="text-xs text-muted">
{getAlertTimelineUnavailableState().text}
</p>
}
>
<div class="flex items-center gap-2">
<p class="text-xs text-error">
{getAlertTimelineFailureState().text}
</p>
<button
class="text-xs text-primary hover:underline"
onClick={() =>
loadIncidentTimeline(getCanonicalAlertId(alert), alert.startTime)
}
>
{getAlertTimelineFailureState().actionLabel}
</button>
</div>
</Show>
</Show>
</Show>
<IncidentTimelinePanel
loading={incidentLoading()[getCanonicalAlertId(alert)]}
error={incidentErrors()[getCanonicalAlertId(alert)]}
timeline={incidentTimelines()[getCanonicalAlertId(alert)]}
filters={incidentEventFilters}
setFilters={setIncidentEventFilters}
filterVariant="panel"
eventCardVariant="alt"
noteDraft={incidentNoteDrafts()[getCanonicalAlertId(alert)] || ''}
onNoteDraftChange={(value) =>
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);
}}
/>
</div>
</Show>
</div>
+4 -1
View File
@@ -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));
};
+31 -216
View File
@@ -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<string>;
setFilters: (next: Set<string>) => 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 (
<div class={getAlertIncidentEventFilterContainerClass('compact')}>
<span class={getAlertIncidentEventFilterLabelClass('compact')}>Filters</span>
<button
type="button"
class={getAlertIncidentEventFilterActionButtonClass()}
onClick={() => props.setFilters(new Set(INCIDENT_EVENT_TYPES))}
>
All
</button>
<button
type="button"
class={getAlertIncidentEventFilterActionButtonClass()}
onClick={() => props.setFilters(new Set())}
>
None
</button>
<For each={INCIDENT_EVENT_TYPES}>
{(type) => {
const selected = () => props.filters().has(type);
return (
<button
type="button"
class={getAlertIncidentEventFilterChipClass(selected(), 'compact')}
onClick={() => toggleFilter(type)}
>
{INCIDENT_EVENT_LABELS[type]}
</button>
);
}}
</For>
</div>
);
}
// History Tab - Comprehensive alert table
function HistoryTab(props: {
hasAIAlertsFeature: () => boolean;
@@ -4984,6 +4921,8 @@ function HistoryTab(props: {
<IncidentEventFilters
filters={resourceIncidentEventFilters}
setFilters={setResourceIncidentEventFilters}
variant="compact"
showQuickSelection
/>
</div>
</Show>
@@ -5369,157 +5308,33 @@ function HistoryTab(props: {
>
<TableRow class="bg-surface-alt border-b border-border">
<TableCell colspan={11} class="p-3">
<Show when={incidentLoading()[rowKey]}>
<p class="text-xs text-muted">
{getAlertTimelineLoadingState().text}
</p>
</Show>
<Show when={!incidentLoading()[rowKey]}>
<Show when={incidentTimelines()[rowKey]}>
{(timeline) => (
<div class="space-y-3">
<div class={getAlertIncidentTimelineMetaRowClass()}>
<span class={getAlertIncidentTimelineHeadingClass()}>
Incident
</span>
<span>{timeline().status}</span>
<Show when={timeline().acknowledged}>
<span
class={getAlertIncidentAcknowledgedBadgeClass()}
>
acknowledged
</span>
</Show>
<Show when={timeline().openedAt}>
<span>
opened{' '}
{new Date(
timeline().openedAt,
).toLocaleString()}
</span>
</Show>
<Show when={timeline().closedAt}>
<span>
closed{' '}
{new Date(
timeline().closedAt as string,
).toLocaleString()}
</span>
</Show>
</div>
{(() => {
const events = timeline().events || [];
const filteredEvents = filterIncidentEvents(
events,
historyIncidentEventFilters(),
);
return (
<>
<Show when={events.length > 0}>
<IncidentEventFilters
filters={historyIncidentEventFilters}
setFilters={
setHistoryIncidentEventFilters
}
/>
</Show>
<Show when={filteredEvents.length > 0}>
<div class="space-y-2">
<For each={filteredEvents}>
{(event) => (
<IncidentTimelineEventCard
event={event}
variant="surface"
/>
)}
</For>
</div>
</Show>
<Show
when={
events.length > 0 &&
filteredEvents.length === 0
}
>
<p class="text-xs text-muted">
{getAlertTimelineFilterEmptyState().text}
</p>
</Show>
<Show when={events.length === 0}>
<p class="text-xs text-muted">
{getAlertTimelineEmptyState().text}
</p>
</Show>
</>
);
})()}
<div class="flex flex-col gap-2">
<textarea
class={getAlertIncidentNoteTextareaClass()}
rows={2}
placeholder={getAlertResourceIncidentNotePlaceholder()}
value={incidentNoteDrafts()[rowKey] || ''}
onInput={(e) => {
const value = e.currentTarget.value;
setIncidentNoteDrafts((prev) => ({
...prev,
[rowKey]: value,
}));
}}
/>
<div class="flex justify-end">
<button
class={getAlertIncidentNoteSaveButtonClass()}
disabled={
incidentNoteSaving().has(rowKey) ||
!(incidentNoteDrafts()[rowKey] || '').trim()
}
onClick={() => {
void saveIncidentNote(
rowKey,
alert.id,
alert.startTime,
);
}}
>
{getAlertResourceIncidentSaveNoteLabel(
incidentNoteSaving().has(rowKey),
)}
</button>
</div>
</div>
</div>
)}
</Show>
<Show when={!incidentTimelines()[rowKey]}>
<Show
when={incidentErrors()[rowKey]}
fallback={
<p class="text-xs text-muted">
{getAlertTimelineUnavailableState().text}
</p>
}
>
<div class="flex items-center gap-2">
<p class="text-xs text-error">
{getAlertTimelineFailureState().text}
</p>
<button
class="text-xs text-primary hover:underline"
onClick={() =>
loadIncidentTimeline(
rowKey,
alert.id,
alert.startTime,
)
}
>
{getAlertTimelineFailureState().actionLabel}
</button>
</div>
</Show>
</Show>
</Show>
<IncidentTimelinePanel
loading={incidentLoading()[rowKey]}
error={incidentErrors()[rowKey]}
timeline={incidentTimelines()[rowKey]}
filters={historyIncidentEventFilters}
setFilters={setHistoryIncidentEventFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft={incidentNoteDrafts()[rowKey] || ''}
onNoteDraftChange={(value) =>
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,
);
}}
/>
</TableCell>
</TableRow>
</Show>
@@ -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([
@@ -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',
);