mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
63d7640a06
commit
3e477b25d9
4 changed files with 91 additions and 7 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,7 +118,28 @@ export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlert
|
|||
{getAlertListEmptyState(props.showAcknowledged)}
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.state.filteredAlerts()}>
|
||||
<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}
|
||||
|
|
@ -128,5 +151,12 @@ export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlert
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue