Extract alert overview state owner

This commit is contained in:
rcourtman 2026-03-22 12:54:44 +00:00
parent 6dbb87071f
commit 24c724f0dd
7 changed files with 342 additions and 161 deletions

View file

@ -177,6 +177,12 @@ Active alert card state, acknowledged badge, and primary/secondary action
button presentation now route through
`frontend-modern/src/utils/alertOverviewPresentation.ts` instead of remaining
inline in `frontend-modern/src/features/alerts/OverviewTab.tsx`.
The canonical overview runtime owner is now
`frontend-modern/src/features/alerts/useAlertOverviewState.ts`, which owns the
derived alert read-model, Last 24 Hours stat refresh, and single/bulk
acknowledge control flow for `frontend-modern/src/features/alerts/OverviewTab.tsx`.
Future overview action or stat behavior should extend that hook instead of
putting acknowledge mutations and timer state back into the tab shell.
Dashboard recent-alert rendering and dashboard alert summary/tone copy now
route through that same alert overview presentation owner and the alert-owned
`frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx` surface instead

View file

@ -429,6 +429,12 @@ while `frontend-modern/src/features/alerts/OverviewTab.tsx` and
surface composition. Future incident timeline fetch, note-save, or expansion
control flow should extend that feature hook rather than forking back into
either tab surface.
Overview alert runtime now follows that same shell-versus-runtime split. The
shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while
`frontend-modern/src/features/alerts/useAlertOverviewState.ts` owns derived
alert stats, filtered ordering, and single/bulk acknowledge runtime behavior.
Future overview control flow should extend that hook rather than restoring
action timers or acknowledge mutations to the tab shell.
Alert history runtime now follows that same pattern. The shell stays in
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx`, while
`frontend-modern/src/features/alerts/useAlertHistoryState.ts` owns history

View file

@ -1,12 +1,13 @@
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
import { createSignal, createMemo, onCleanup, For, Show, createEffect } from 'solid-js';
import { createSignal, onCleanup, For, Show, createEffect } from 'solid-js';
import { useLocation } from '@solidjs/router';
import type { Alert } from '@/types/api';
import type { Override } from './types';
import { alertTypeDisplayLabel } from './helpers';
import { getCanonicalAlertId } from './identity';
import { useAlertOverviewState } from './useAlertOverviewState';
import { useAlertIncidentTimelineState } from './useAlertIncidentTimelineState';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
@ -21,8 +22,7 @@ import {
getAlertOverviewSecondaryActionClass,
getAlertOverviewStartedAtClass,
} from '@/utils/alertOverviewPresentation';
// Overview Tab - Shows current alert status
export function OverviewTab(props: {
overrides: Override[];
activeAlerts: Record<string, Alert>;
@ -37,9 +37,20 @@ export function OverviewTab(props: {
}) {
const location = useLocation();
let hashScrollRafId: number | undefined;
const pendingProcessingResetTimeouts = new Set<number>();
// Loading states for buttons
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
const [lastHashScrolled, setLastHashScrolled] = createSignal<string | null>(null);
const {
alertStats,
filteredAlerts,
processingAlerts,
bulkAckProcessing,
handleAlertAcknowledgement,
handleBulkAcknowledge,
} = useAlertOverviewState({
activeAlerts: () => props.activeAlerts,
overrides: () => props.overrides,
showAcknowledged: props.showAcknowledged,
updateAlert: props.updateAlert,
});
const {
incidentTimelines,
incidentLoading,
@ -54,63 +65,6 @@ export function OverviewTab(props: {
setIncidentNoteDraft,
saveIncidentNote,
} = useAlertIncidentTimelineState();
const [lastHashScrolled, setLastHashScrolled] = createSignal<string | null>(null);
// Tick every 60s so the "Last 24 Hours" count stays fresh as alerts age out
const [tick, setTick] = createSignal(Date.now());
const tickInterval = setInterval(() => setTick(Date.now()), 60_000);
onCleanup(() => clearInterval(tickInterval));
const processingReleaseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const clearProcessingReleaseTimer = (alertIdentifier: string) => {
const timer = processingReleaseTimers.get(alertIdentifier);
if (timer === undefined) {
return;
}
clearTimeout(timer);
processingReleaseTimers.delete(alertIdentifier);
};
onCleanup(() => {
processingReleaseTimers.forEach((timer) => clearTimeout(timer));
processingReleaseTimers.clear();
});
// Get alert stats from actual active alerts
const alertStats = createMemo(() => {
// Access the store properly for reactivity
const alertIds = Object.keys(props.activeAlerts);
const alerts = alertIds.map((id) => props.activeAlerts[id]);
return {
active: alerts.filter((a) => !a.acknowledged).length,
acknowledged: alerts.filter((a) => a.acknowledged).length,
total24h: alerts.filter((a) => {
const age = tick() - new Date(a.startTime).getTime();
return age >= 0 && age < 86_400_000;
}).length,
overrides: props.overrides.length,
};
});
const filteredAlerts = createMemo(() => {
const alerts = Object.values(props.activeAlerts);
// Sort: unacknowledged first, then by start time (newest first)
return alerts
.filter((alert) => props.showAcknowledged() || !alert.acknowledged)
.sort((a, b) => {
// Acknowledged status comparison first
if (a.acknowledged !== b.acknowledged) {
return a.acknowledged ? 1 : -1; // Unacknowledged first
}
// Then by time
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
});
});
const unacknowledgedAlerts = createMemo(() =>
Object.values(props.activeAlerts).filter((alert) => !alert.acknowledged),
);
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
const scrollToAlertHash = () => {
const hash = location.hash;
@ -147,10 +101,6 @@ export function OverviewTab(props: {
cancelAnimationFrame(hashScrollRafId);
hashScrollRafId = undefined;
}
pendingProcessingResetTimeouts.forEach((timeoutId) => {
window.clearTimeout(timeoutId);
});
pendingProcessingResetTimeouts.clear();
});
return (
@ -319,44 +269,8 @@ export function OverviewTab(props: {
type="button"
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-200 transition-colors hover:bg-blue-100 dark:hover:bg-blue-900 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={bulkAckProcessing()}
onClick={async () => {
if (bulkAckProcessing()) return;
const pending = unacknowledgedAlerts();
if (pending.length === 0) {
return;
}
setBulkAckProcessing(true);
try {
const result = await AlertsAPI.bulkAcknowledge(
pending.map((alert) => getCanonicalAlertId(alert)),
);
const successes = result.results.filter((r) => r.success);
const failures = result.results.filter((r) => !r.success);
successes.forEach((res) => {
props.updateAlert(res.alertIdentifier, {
acknowledged: true,
ackTime: new Date().toISOString(),
});
});
if (successes.length > 0) {
notificationStore.success(
`Acknowledged ${successes.length} ${successes.length === 1 ? 'alert' : 'alerts'}.`,
);
}
if (failures.length > 0) {
notificationStore.error(
`Failed to acknowledge ${failures.length} ${failures.length === 1 ? 'alert' : 'alerts'}.`,
);
}
} catch (error) {
logger.error('Bulk acknowledge failed', error);
notificationStore.error('Failed to acknowledge alerts');
} finally {
setBulkAckProcessing(false);
}
onClick={() => {
void handleBulkAcknowledge();
}}
>
{bulkAckProcessing()
@ -454,62 +368,7 @@ export function OverviewTab(props: {
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
const alertIdentifier = getCanonicalAlertId(alert);
// Prevent double-clicks
if (processingAlerts().has(alertIdentifier)) return;
setProcessingAlerts(
(prev) => new Set(prev).add(alertIdentifier),
);
// Store current state to avoid race conditions
const wasAcknowledged = alert.acknowledged;
try {
if (wasAcknowledged) {
// Call API first, only update local state if successful
await AlertsAPI.unacknowledge(alertIdentifier);
// Only update local state after successful API call
props.updateAlert(alertIdentifier, {
acknowledged: false,
ackTime: undefined,
ackUser: undefined,
});
notificationStore.success('Alert restored');
} else {
// Call API first, only update local state if successful
await AlertsAPI.acknowledge(alertIdentifier);
// Only update local state after successful API call
props.updateAlert(alertIdentifier, {
acknowledged: true,
ackTime: new Date().toISOString(),
});
notificationStore.success('Alert acknowledged');
}
} catch (err) {
logger.error(
`Failed to ${wasAcknowledged ? 'unacknowledge' : 'acknowledge'} alert:`,
err,
);
notificationStore.error(
`Failed to ${wasAcknowledged ? 'restore' : 'acknowledge'} alert`,
);
// Don't update local state on error - let WebSocket keep the correct state
} finally {
// Keep button disabled for longer to prevent race conditions with WebSocket updates
clearProcessingReleaseTimer(alertIdentifier);
const timer = setTimeout(() => {
processingReleaseTimers.delete(alertIdentifier);
setProcessingAlerts((prev) => {
const next = new Set(prev);
next.delete(alertIdentifier);
return next;
});
}, 1500); // 1.5 seconds to allow server to process and WebSocket to sync
processingReleaseTimers.set(alertIdentifier, timer);
}
await handleAlertAcknowledgement(alert);
}}
>
{processingAlerts().has(getCanonicalAlertId(alert))

View file

@ -0,0 +1,119 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertsAPI } from '@/api/alerts';
import { notificationStore } from '@/stores/notifications';
import type { Alert } from '@/types/api';
import { useAlertOverviewState } from '../useAlertOverviewState';
vi.mock('@/api/alerts', () => ({
AlertsAPI: {
acknowledge: vi.fn(),
bulkAcknowledge: vi.fn(),
unacknowledge: vi.fn(),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
},
}));
function makeAlert(id: string, startTime: string, acknowledged = false): Alert {
return {
id,
type: 'cpu',
level: 'warning',
resourceId: `vm-${id}`,
resourceName: `VM ${id}`,
node: 'node-1',
message: `CPU high on ${id}`,
startTime,
acknowledged,
} as Alert;
}
describe('useAlertOverviewState', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-22T12:00:00Z'));
vi.mocked(AlertsAPI.acknowledge).mockReset();
vi.mocked(AlertsAPI.unacknowledge).mockReset();
vi.mocked(AlertsAPI.bulkAcknowledge).mockReset();
vi.mocked(notificationStore.success).mockReset();
vi.mocked(notificationStore.error).mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('owns overview stats, filtering, and acknowledge flows outside the tab shell', async () => {
const now = Date.now();
const [activeAlerts] = createSignal<Record<string, Alert>>({
warning: makeAlert('warning', new Date(now - 60_000).toISOString(), false),
acknowledged: makeAlert('acknowledged', new Date(now - 2 * 60_000).toISOString(), true),
old: makeAlert('old', new Date(now - 3 * 86_400_000).toISOString(), false),
});
const [showAcknowledged] = createSignal(false);
const updateAlert = vi.fn();
vi.mocked(AlertsAPI.acknowledge).mockResolvedValue(undefined as any);
vi.mocked(AlertsAPI.unacknowledge).mockResolvedValue(undefined as any);
vi.mocked(AlertsAPI.bulkAcknowledge).mockResolvedValue({
results: [
{ alertIdentifier: 'warning', success: true },
{ alertIdentifier: 'old', success: false },
],
} as any);
const { result } = renderHook(() =>
useAlertOverviewState({
activeAlerts,
overrides: () => [],
showAcknowledged,
updateAlert,
}),
);
expect(result.alertStats()).toMatchObject({
active: 2,
acknowledged: 1,
total24h: 2,
overrides: 0,
});
expect(result.filteredAlerts().map((alert) => alert.id)).toEqual(['warning', 'old']);
await result.handleAlertAcknowledgement(activeAlerts().warning);
expect(AlertsAPI.acknowledge).toHaveBeenCalledWith('warning');
expect(updateAlert).toHaveBeenCalledWith(
'warning',
expect.objectContaining({ acknowledged: true }),
);
expect(notificationStore.success).toHaveBeenCalledWith('Alert acknowledged');
expect(result.processingAlerts().has('warning')).toBe(true);
vi.advanceTimersByTime(1500);
expect(result.processingAlerts().has('warning')).toBe(false);
await result.handleBulkAcknowledge();
expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['warning', 'old']);
expect(updateAlert).toHaveBeenCalledWith(
'warning',
expect.objectContaining({ acknowledged: true }),
);
expect(notificationStore.error).toHaveBeenCalledWith('Failed to acknowledge 1 alert.');
});
});

View file

@ -0,0 +1,172 @@
import { createMemo, createSignal, onCleanup } from 'solid-js';
import type { Accessor } from 'solid-js';
import { AlertsAPI } from '@/api/alerts';
import { notificationStore } from '@/stores/notifications';
import type { Alert } from '@/types/api';
import { logger } from '@/utils/logger';
import { getCanonicalAlertId } from './identity';
import type { Override } from './types';
export interface UseAlertOverviewStateProps {
activeAlerts: Accessor<Record<string, Alert>>;
overrides: Accessor<Override[]>;
showAcknowledged: Accessor<boolean>;
updateAlert: (alertIdentifier: string, updates: Partial<Alert>) => void;
}
export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
const [tick, setTick] = createSignal(Date.now());
const tickInterval = setInterval(() => setTick(Date.now()), 60_000);
const processingReleaseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const clearProcessingReleaseTimer = (alertIdentifier: string) => {
const timer = processingReleaseTimers.get(alertIdentifier);
if (timer === undefined) {
return;
}
clearTimeout(timer);
processingReleaseTimers.delete(alertIdentifier);
};
onCleanup(() => {
clearInterval(tickInterval);
processingReleaseTimers.forEach((timer) => clearTimeout(timer));
processingReleaseTimers.clear();
});
const alertStats = createMemo(() => {
const alerts = Object.values(props.activeAlerts());
return {
active: alerts.filter((alert) => !alert.acknowledged).length,
acknowledged: alerts.filter((alert) => alert.acknowledged).length,
total24h: alerts.filter((alert) => {
const age = tick() - new Date(alert.startTime).getTime();
return age >= 0 && age < 86_400_000;
}).length,
overrides: props.overrides().length,
};
});
const filteredAlerts = createMemo(() =>
Object.values(props.activeAlerts())
.filter((alert) => props.showAcknowledged() || !alert.acknowledged)
.sort((a, b) => {
if (a.acknowledged !== b.acknowledged) {
return a.acknowledged ? 1 : -1;
}
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
}),
);
const unacknowledgedAlerts = createMemo(() =>
Object.values(props.activeAlerts()).filter((alert) => !alert.acknowledged),
);
const releaseAlertProcessing = (alertIdentifier: string) => {
clearProcessingReleaseTimer(alertIdentifier);
const timer = setTimeout(() => {
processingReleaseTimers.delete(alertIdentifier);
setProcessingAlerts((prev) => {
const next = new Set(prev);
next.delete(alertIdentifier);
return next;
});
}, 1500);
processingReleaseTimers.set(alertIdentifier, timer);
};
const handleAlertAcknowledgement = async (alert: Alert) => {
const alertIdentifier = getCanonicalAlertId(alert);
if (processingAlerts().has(alertIdentifier)) {
return;
}
setProcessingAlerts((prev) => new Set(prev).add(alertIdentifier));
const wasAcknowledged = alert.acknowledged;
try {
if (wasAcknowledged) {
await AlertsAPI.unacknowledge(alertIdentifier);
props.updateAlert(alertIdentifier, {
acknowledged: false,
ackTime: undefined,
ackUser: undefined,
});
notificationStore.success('Alert restored');
} else {
await AlertsAPI.acknowledge(alertIdentifier);
props.updateAlert(alertIdentifier, {
acknowledged: true,
ackTime: new Date().toISOString(),
});
notificationStore.success('Alert acknowledged');
}
} catch (error) {
logger.error(
`Failed to ${wasAcknowledged ? 'unacknowledge' : 'acknowledge'} alert:`,
error,
);
notificationStore.error(`Failed to ${wasAcknowledged ? 'restore' : 'acknowledge'} alert`);
} finally {
releaseAlertProcessing(alertIdentifier);
}
};
const handleBulkAcknowledge = async () => {
if (bulkAckProcessing()) {
return;
}
const pending = unacknowledgedAlerts();
if (pending.length === 0) {
return;
}
setBulkAckProcessing(true);
try {
const result = await AlertsAPI.bulkAcknowledge(
pending.map((alert) => getCanonicalAlertId(alert)),
);
const successes = result.results.filter((entry) => entry.success);
const failures = result.results.filter((entry) => !entry.success);
successes.forEach((entry) => {
props.updateAlert(entry.alertIdentifier, {
acknowledged: true,
ackTime: new Date().toISOString(),
});
});
if (successes.length > 0) {
notificationStore.success(
`Acknowledged ${successes.length} ${successes.length === 1 ? 'alert' : 'alerts'}.`,
);
}
if (failures.length > 0) {
notificationStore.error(
`Failed to acknowledge ${failures.length} ${failures.length === 1 ? 'alert' : 'alerts'}.`,
);
}
} catch (error) {
logger.error('Bulk acknowledge failed', error);
notificationStore.error('Failed to acknowledge alerts');
} finally {
setBulkAckProcessing(false);
}
};
return {
alertStats,
filteredAlerts,
unacknowledgedAlerts,
processingAlerts,
bulkAckProcessing,
handleAlertAcknowledgement,
handleBulkAcknowledge,
};
}

View file

@ -5,8 +5,10 @@ import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigura
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw';
import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw';
import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?raw';
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
@ -221,6 +223,14 @@ describe('tab path helpers', () => {
);
expect(alertIncidentTimelineStateSource).toContain('AlertsAPI.getIncidentTimeline');
expect(alertIncidentTimelineStateSource).toContain('AlertsAPI.addIncidentNote');
expect(alertOverviewTabSource).toContain('useAlertOverviewState');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.acknowledge');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.unacknowledge');
expect(alertOverviewStateSource).toContain('export function useAlertOverviewState');
expect(alertOverviewStateSource).toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewStateSource).toContain('AlertsAPI.acknowledge');
expect(alertOverviewStateSource).toContain('AlertsAPI.unacknowledge');
expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions');
expect(alertThresholdsTabSource).toContain('ThresholdsTable');
expect(thresholdsTableSource).toContain(

View file

@ -329,6 +329,7 @@ import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigura
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw';
@ -3612,6 +3613,7 @@ describe('frontend resource type boundaries', () => {
it('keeps alert incident timeline state copy in a shared presentation utility', () => {
expect(alertOverviewTabSource).toContain('IncidentTimelinePanel');
expect(alertOverviewTabSource).toContain('useAlertIncidentTimelineState');
expect(alertOverviewTabSource).toContain('useAlertOverviewState');
expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterContainerClass');
expect(alertIncidentEventFiltersSource).toContain('getAlertIncidentEventFilterChipClass');
expect(alertIncidentEventFiltersSource).toContain(
@ -3644,6 +3646,9 @@ describe('frontend resource type boundaries', () => {
expect(alertOverviewTabSource).toContain('getAlertOverviewStartedAtClass');
expect(alertOverviewTabSource).toContain('getAlertOverviewPrimaryActionClass');
expect(alertOverviewTabSource).toContain('getAlertOverviewSecondaryActionClass');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.acknowledge');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.unacknowledge');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.getIncidentTimeline');
expect(alertOverviewTabSource).not.toContain('AlertsAPI.addIncidentNote');
expect(alertOverviewTabSource).not.toContain('Loading timeline...');
@ -3651,6 +3656,10 @@ describe('frontend resource type boundaries', () => {
expect(alertOverviewTabSource).not.toContain('No timeline events yet.');
expect(alertOverviewTabSource).not.toContain('No incident timeline available.');
expect(alertOverviewTabSource).not.toContain('Failed to load timeline.');
expect(alertOverviewStateSource).toContain('export function useAlertOverviewState');
expect(alertOverviewStateSource).toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewStateSource).toContain('AlertsAPI.acknowledge');
expect(alertOverviewStateSource).toContain('AlertsAPI.unacknowledge');
expect(alertOverviewPresentationSource).toContain(
'export function getAlertTimelineLoadingState',
);