feat(alerts): group related alerts by resource hierarchy on overview

Alerts that share a resource ancestor (e.g., ZFS pool + device within
that pool) are now grouped on the alerts overview page. The primary
alert (highest severity, most recent) is shown by default with a
'+N related' toggle that expands to reveal the related alerts.

Grouping uses resourceId nesting: alerts with 3+ path segments are
sub-components and share the group key of their parent (first 2
segments). Alerts on different subsystems remain separate.

Single-alert groups render exactly as before — no visual change when
there's nothing to group.
This commit is contained in:
rcourtman 2026-06-27 14:51:45 +01:00
parent 63d7640a06
commit 3e477b25d9
4 changed files with 91 additions and 7 deletions

View file

@ -31,6 +31,7 @@ const ALLOWLIST = new Set([
'src/components/Storage/storageSourceOptions.ts',
'src/components/Storage/useStorageExpansionState.ts',
'src/components/Storage/useStorageResourceHighlight.ts',
'src/features/alerts/useAlertGroupExpansion.ts',
'src/components/Infrastructure/resourceBadges.ts',
'src/components/Settings/reportingResourceTypes.ts',
'src/utils/canonicalResourceTypes.ts',

View file

@ -12,6 +12,7 @@ import {
} from '@/utils/alertOverviewPresentation';
import { AlertOverviewAlertCard } from './AlertOverviewAlertCard';
import { useAlertGroupExpansion } from './useAlertGroupExpansion';
import type { AlertIncidentTimelineState } from './useAlertIncidentTimelineState';
import type { AlertOverviewState } from './useAlertOverviewState';
@ -25,6 +26,7 @@ interface AlertOverviewActiveAlertsSectionProps {
}
export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlertsSectionProps) {
const { isGroupExpanded, toggleGroup } = useAlertGroupExpansion();
return (
<div>
<SectionHeader title={getAlertOverviewActiveSectionTitle()} size="md" class="mb-3" />
@ -116,13 +118,41 @@ export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlert
{getAlertListEmptyState(props.showAcknowledged)}
</div>
</Show>
<For each={props.state.filteredAlerts()}>
{(alert) => (
<AlertOverviewAlertCard
alert={alert}
state={props.state}
timelineState={props.timelineState}
/>
<For each={props.state.groupedAlerts()}>
{(group) => (
<div>
<AlertOverviewAlertCard
alert={group.primary}
state={props.state}
timelineState={props.timelineState}
/>
<Show when={group.related.length > 0}>
<div class="ml-4 border-l-2 border-border pl-3 mt-1">
<button
type="button"
class="text-xs text-muted hover:text-base-content transition-colors py-1"
onClick={() => toggleGroup(group.key)}
>
{isGroupExpanded(group.key)
? 'Hide'
: `+${group.related.length} related`}
</button>
<Show when={isGroupExpanded(group.key)}>
<div class="space-y-2 mt-1">
<For each={group.related}>
{(alert) => (
<AlertOverviewAlertCard
alert={alert}
state={props.state}
timelineState={props.timelineState}
/>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
)}
</For>
</div>

View file

@ -0,0 +1,18 @@
import { createSignal } from 'solid-js';
export function useAlertGroupExpansion() {
const [expandedGroups, setExpandedGroups] = createSignal<Set<string>>(new Set());
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const isGroupExpanded = (key: string) => expandedGroups().has(key);
return { isGroupExpanded, toggleGroup };
}

View file

@ -5,6 +5,21 @@ import type { Alert } from '@/types/api';
import type { Override } from './types';
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
export interface AlertGroup {
key: string;
primary: Alert;
related: Alert[];
}
export function computeAlertGroupKey(alert: Alert): string {
const rid = alert.resourceId ?? '';
const segments = rid.split('/');
if (segments.length > 2) {
return segments.slice(0, -1).join('/');
}
return rid;
}
export interface UseAlertOverviewStateProps {
activeAlerts: Accessor<Record<string, Alert>>;
overrides: Accessor<Override[]>;
@ -66,9 +81,29 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
}),
);
const groupedAlerts = createMemo<AlertGroup[]>(() => {
const sorted = filteredAlerts();
const groups = new Map<string, Alert[]>();
for (const alert of sorted) {
const key = computeAlertGroupKey(alert);
const existing = groups.get(key);
if (existing) {
existing.push(alert);
} else {
groups.set(key, [alert]);
}
}
const result: AlertGroup[] = [];
for (const [key, alerts] of groups) {
result.push({ key, primary: alerts[0], related: alerts.slice(1) });
}
return result;
});
return {
alertStats,
filteredAlerts,
groupedAlerts,
unacknowledgedAlerts,
processingAlerts,
bulkAckProcessing,