Simplify alert incident timeline presentation

This commit is contained in:
rcourtman
2026-03-20 14:20:09 +00:00
parent 263f3867d5
commit d6ca24d252
7 changed files with 165 additions and 56 deletions
@@ -123,6 +123,13 @@ 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`.
That same resource incident panel now treats collapsed incident activity as a
canonical alert read-model summary rather than a page-local sentence. The
collapsed row must summarize filtered incident events by canonical event type
order and reuse the shared event-card renderer for expanded incident detail,
so the alert history page does not drift away from the overview timeline when
canonical lifecycle or remediation events are added.
Active alert card state, acknowledged badge, and primary/secondary action
button presentation now route through
`frontend-modern/src/utils/alertOverviewPresentation.ts` instead of remaining
@@ -149,6 +149,12 @@ through `frontend-modern/src/utils/alertIncidentPresentation.ts` instead of
maintaining page-local incident panel styling inside
`frontend-modern/src/pages/Alerts.tsx`.
The resource incident panel's collapsed activity summary is now part of that
same shared primitive boundary. Event-type count chips, visible-event copy,
and the summary-ordering helper in `frontend-modern/src/features/alerts/types.ts`
must stay shared across alert timeline surfaces instead of rebuilding
page-local event summaries or bespoke incident-card markup.
Shared primitive consumers that split status-dot tone and status-text tone
must now keep both values routed through the same exported presentation helper.
Feature cards such as RAID status may not call shadow local aliases that drift
@@ -225,3 +225,46 @@ export const filterIncidentEvents = (
}
return events.filter((event) => filters.has(event.type));
};
export interface IncidentEventSummary {
type: string;
label: string;
count: number;
}
export const summarizeIncidentEvents = (
events: IncidentEvent[] | undefined,
): IncidentEventSummary[] => {
if (!events || events.length === 0) {
return [];
}
const counts = new Map<string, number>();
const unknownTypes: string[] = [];
events.forEach((event) => {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
if (
!Object.prototype.hasOwnProperty.call(INCIDENT_EVENT_LABELS, event.type) &&
!unknownTypes.includes(event.type)
) {
unknownTypes.push(event.type);
}
});
const knownSummary = INCIDENT_EVENT_TYPES.flatMap((type) => {
const count = counts.get(type);
if (!count) {
return [];
}
return [{ type, label: INCIDENT_EVENT_LABELS[type], count }];
});
const unknownSummary = unknownTypes.map((type) => ({
type,
label: type,
count: counts.get(type) ?? 0,
}));
return [...knownSummary, ...unknownSummary];
};
+36 -55
View File
@@ -78,6 +78,8 @@ import {
getAlertIncidentTimelineHeadingClass,
getAlertIncidentTimelineMetaRowClass,
getAlertIncidentTimelineOutputClass,
getAlertResourceIncidentActivityChipClass,
getAlertResourceIncidentActivitySummaryClass,
getAlertResourceIncidentAcknowledgedByLabel,
getAlertResourceIncidentCardClass,
getAlertResourceIncidentCountLabel,
@@ -284,6 +286,7 @@ import {
INCIDENT_EVENT_TYPES,
GROUPING_WINDOW_DEFAULT_SECONDS,
INCIDENT_EVENT_LABELS,
summarizeIncidentEvents,
fallbackCooldownMinutes,
clampCooldownMinutes,
} from '@/features/alerts/types';
@@ -5005,6 +5008,7 @@ function HistoryTab(props: {
events,
resourceIncidentEventFilters(),
);
const eventSummary = summarizeIncidentEvents(filteredEvents);
const recentEvents =
filteredEvents.length > 6
? filteredEvents.slice(filteredEvents.length - 6)
@@ -5048,18 +5052,35 @@ function HistoryTab(props: {
</Show>
<Show when={events.length > 0}>
<div class={getAlertResourceIncidentSummaryRowClass()}>
<span>
<Show
when={filteredEvents.length > 0}
fallback={
<span>
{getAlertResourceIncidentFilteredEventsEmptyState().text}
</span>
}
>
Last event: {lastEvent?.summary}
</Show>
</span>
<Show
when={filteredEvents.length > 0}
fallback={
<span>
{getAlertResourceIncidentFilteredEventsEmptyState().text}
</span>
}
>
<div class={getAlertResourceIncidentActivitySummaryClass()}>
<span class="text-[10px] font-medium uppercase tracking-wide text-muted">
Activity
</span>
<For each={eventSummary}>
{(summary) => (
<span class={getAlertResourceIncidentActivityChipClass()}>
{summary.label} {summary.count}
</span>
)}
</For>
<span>
{filteredEvents.length !== events.length
? `${filteredEvents.length}/${events.length} events`
: `${events.length} event${events.length === 1 ? '' : 's'}`}
</span>
<Show when={lastEvent}>
<span>Latest: {lastEvent?.summary}</span>
</Show>
</div>
</Show>
<button
type="button"
class={getAlertResourceIncidentToggleButtonClass()}
@@ -5081,54 +5102,14 @@ function HistoryTab(props: {
>
<For each={recentEvents}>
{(event) => (
<div class="rounded border border-border bg-surface-alt p-2">
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span class="font-medium text-base-content">
{event.summary}
</span>
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
<Show
when={
event.details &&
(event.details as { note?: string }).note
}
>
<p class="text-xs text-base-content mt-1">
{(event.details as { note?: string }).note}
</p>
</Show>
<Show
when={
event.details &&
(event.details as { command?: string }).command
}
>
<p class="text-xs text-base-content mt-1 font-mono">
{(event.details as { command?: string }).command}
</p>
</Show>
<Show
when={
event.details &&
(event.details as { output_excerpt?: string })
.output_excerpt
}
>
<p class="text-xs text-muted mt-1">
{
(event.details as { output_excerpt?: string })
.output_excerpt
}
</p>
</Show>
</div>
<IncidentTimelineEventCard event={event} variant="alt" />
)}
</For>
<Show when={filteredEvents.length > recentEvents.length}>
<Show when={filteredEvents.length > 0}>
<p class="text-[10px] text-muted">
{getAlertResourceIncidentTruncatedEventsLabel(
recentEvents.length,
filteredEvents.length,
)}
</p>
</Show>
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
ALERT_TAB_SEGMENTS,
pathForTab,
summarizeIncidentEvents,
tabFromPath,
clampCooldownMinutes,
fallbackCooldownMinutes,
@@ -34,6 +35,8 @@ import {
getAlertIncidentTimelineHeadingClass,
getAlertIncidentTimelineMetaRowClass,
getAlertIncidentTimelineOutputClass,
getAlertResourceIncidentActivityChipClass,
getAlertResourceIncidentActivitySummaryClass,
getAlertResourceIncidentCardClass,
getAlertResourceIncidentSummaryRowClass,
getAlertResourceIncidentToggleButtonClass,
@@ -273,10 +276,52 @@ describe('incident timeline presentation helpers', () => {
expect(getAlertResourceIncidentSummaryRowClass()).toBe(
'mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted',
);
expect(getAlertResourceIncidentActivitySummaryClass()).toBe(
'flex flex-wrap items-center gap-1.5',
);
expect(getAlertResourceIncidentActivityChipClass()).toBe(
'rounded bg-surface-alt px-2 py-0.5 text-[10px] font-medium text-base-content',
);
expect(getAlertResourceIncidentToggleButtonClass()).toBe(
'px-2 py-1 text-[10px] border rounded-md border-border text-muted hover:bg-surface-hover',
);
expect(getAlertResourceIncidentTruncatedEventsLabel(6)).toBe('Showing last 6 events');
expect(getAlertResourceIncidentTruncatedEventsLabel(6, 6)).toBe('Showing 6 events');
expect(getAlertResourceIncidentTruncatedEventsLabel(6, 9)).toBe('Showing last 6 of 9 events');
});
});
describe('incident event summaries', () => {
it('summarizes incident events in canonical order and retains unknown event types', () => {
expect(
summarizeIncidentEvents([
{ id: '1', type: 'note', timestamp: '2026-03-20T10:00:00Z', summary: 'Added note' },
{
id: '2',
type: 'alert_fired',
timestamp: '2026-03-20T10:01:00Z',
summary: 'Alert fired',
},
{ id: '3', type: 'note', timestamp: '2026-03-20T10:02:00Z', summary: 'Added note' },
{
id: '4',
type: 'command',
timestamp: '2026-03-20T10:03:00Z',
summary: 'Command executed',
},
{
id: '5',
type: 'operator_followup',
timestamp: '2026-03-20T10:04:00Z',
summary: 'Operator follow-up',
},
]),
).toEqual([
{ type: 'alert_fired', label: 'Fired', count: 1 },
{ type: 'command', label: 'Cmd', count: 1 },
{ type: 'note', label: 'Note', count: 2 },
{ type: 'operator_followup', label: 'operator_followup', count: 1 },
]);
});
});
@@ -36,6 +36,8 @@ import {
getAlertIncidentTimelineDetailClass,
getAlertIncidentTimelineCommandClass,
getAlertIncidentTimelineOutputClass,
getAlertResourceIncidentActivityChipClass,
getAlertResourceIncidentActivitySummaryClass,
getAlertResourceIncidentCardClass,
getAlertResourceIncidentSummaryRowClass,
getAlertResourceIncidentToggleButtonClass,
@@ -195,9 +197,17 @@ describe('alertIncidentPresentation', () => {
expect(getAlertResourceIncidentSummaryRowClass()).toBe(
'mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted',
);
expect(getAlertResourceIncidentActivitySummaryClass()).toBe(
'flex flex-wrap items-center gap-1.5',
);
expect(getAlertResourceIncidentActivityChipClass()).toBe(
'rounded bg-surface-alt px-2 py-0.5 text-[10px] font-medium text-base-content',
);
expect(getAlertResourceIncidentToggleButtonClass()).toBe(
'px-2 py-1 text-[10px] border rounded-md border-border text-muted hover:bg-surface-hover',
);
expect(getAlertResourceIncidentTruncatedEventsLabel(6)).toBe('Showing last 6 events');
expect(getAlertResourceIncidentTruncatedEventsLabel(6, 6)).toBe('Showing 6 events');
expect(getAlertResourceIncidentTruncatedEventsLabel(6, 9)).toBe('Showing last 6 of 9 events');
});
});
@@ -259,10 +259,27 @@ export function getAlertResourceIncidentSummaryRowClass(): string {
return 'mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted';
}
export function getAlertResourceIncidentActivitySummaryClass(): string {
return 'flex flex-wrap items-center gap-1.5';
}
export function getAlertResourceIncidentActivityChipClass(): string {
return 'rounded bg-surface-alt px-2 py-0.5 text-[10px] font-medium text-base-content';
}
export function getAlertResourceIncidentToggleButtonClass(): string {
return 'px-2 py-1 text-[10px] border rounded-md border-border text-muted hover:bg-surface-hover';
}
export function getAlertResourceIncidentTruncatedEventsLabel(count: number): string {
export function getAlertResourceIncidentTruncatedEventsLabel(
count: number,
totalCount?: number,
): string {
if (typeof totalCount === 'number') {
if (totalCount <= count) {
return `Showing ${totalCount} event${totalCount === 1 ? '' : 's'}`;
}
return `Showing last ${count} of ${totalCount} events`;
}
return `Showing last ${count} events`;
}