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/storageSourceOptions.ts',
|
||||||
'src/components/Storage/useStorageExpansionState.ts',
|
'src/components/Storage/useStorageExpansionState.ts',
|
||||||
'src/components/Storage/useStorageResourceHighlight.ts',
|
'src/components/Storage/useStorageResourceHighlight.ts',
|
||||||
|
'src/features/alerts/useAlertGroupExpansion.ts',
|
||||||
'src/components/Infrastructure/resourceBadges.ts',
|
'src/components/Infrastructure/resourceBadges.ts',
|
||||||
'src/components/Settings/reportingResourceTypes.ts',
|
'src/components/Settings/reportingResourceTypes.ts',
|
||||||
'src/utils/canonicalResourceTypes.ts',
|
'src/utils/canonicalResourceTypes.ts',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '@/utils/alertOverviewPresentation';
|
} from '@/utils/alertOverviewPresentation';
|
||||||
|
|
||||||
import { AlertOverviewAlertCard } from './AlertOverviewAlertCard';
|
import { AlertOverviewAlertCard } from './AlertOverviewAlertCard';
|
||||||
|
import { useAlertGroupExpansion } from './useAlertGroupExpansion';
|
||||||
import type { AlertIncidentTimelineState } from './useAlertIncidentTimelineState';
|
import type { AlertIncidentTimelineState } from './useAlertIncidentTimelineState';
|
||||||
import type { AlertOverviewState } from './useAlertOverviewState';
|
import type { AlertOverviewState } from './useAlertOverviewState';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ interface AlertOverviewActiveAlertsSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlertsSectionProps) {
|
export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlertsSectionProps) {
|
||||||
|
const { isGroupExpanded, toggleGroup } = useAlertGroupExpansion();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title={getAlertOverviewActiveSectionTitle()} size="md" class="mb-3" />
|
<SectionHeader title={getAlertOverviewActiveSectionTitle()} size="md" class="mb-3" />
|
||||||
|
|
@ -116,13 +118,41 @@ export function AlertOverviewActiveAlertsSection(props: AlertOverviewActiveAlert
|
||||||
{getAlertListEmptyState(props.showAcknowledged)}
|
{getAlertListEmptyState(props.showAcknowledged)}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.state.filteredAlerts()}>
|
<For each={props.state.groupedAlerts()}>
|
||||||
{(alert) => (
|
{(group) => (
|
||||||
<AlertOverviewAlertCard
|
<div>
|
||||||
alert={alert}
|
<AlertOverviewAlertCard
|
||||||
state={props.state}
|
alert={group.primary}
|
||||||
timelineState={props.timelineState}
|
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>
|
</For>
|
||||||
</div>
|
</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 type { Override } from './types';
|
||||||
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
|
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 {
|
export interface UseAlertOverviewStateProps {
|
||||||
activeAlerts: Accessor<Record<string, Alert>>;
|
activeAlerts: Accessor<Record<string, Alert>>;
|
||||||
overrides: Accessor<Override[]>;
|
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 {
|
return {
|
||||||
alertStats,
|
alertStats,
|
||||||
filteredAlerts,
|
filteredAlerts,
|
||||||
|
groupedAlerts,
|
||||||
unacknowledgedAlerts,
|
unacknowledgedAlerts,
|
||||||
processingAlerts,
|
processingAlerts,
|
||||||
bulkAckProcessing,
|
bulkAckProcessing,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue