mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-08 05:41:31 +00:00
Simplify alert incident timeline presentation
This commit is contained in:
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user