Extract shared alert acknowledgement owner

This commit is contained in:
rcourtman 2026-03-22 14:28:13 +00:00
parent 24c724f0dd
commit bd362a28da
13 changed files with 505 additions and 204 deletions

View file

@ -177,12 +177,19 @@ 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.
The canonical shared alert-acknowledgement runtime owner is now
`frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts`, which
owns optimistic single/bulk acknowledge control flow, restore behavior, and
notification feedback for both
`frontend-modern/src/features/alerts/useAlertOverviewState.ts` and
`frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx`.
`frontend-modern/src/features/alerts/useAlertOverviewState.ts` now owns the
derived alert read-model and Last 24 Hours stat refresh for
`frontend-modern/src/features/alerts/OverviewTab.tsx`, while composing that
shared acknowledgement owner instead of keeping its own alert mutation fork.
Future overview or dashboard recent-alert action behavior should extend that
shared acknowledgement hook instead of putting acknowledge mutations back into
either render 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

@ -218,6 +218,15 @@ customization surfaces. Lane-owned widgets like recent alerts, storage,
and recovery must continue to route through their own subsystem owners instead
of drifting back into a page-local dashboard panel cluster.
Feature-owned alert shells under `frontend-modern/src/features/alerts/` now
also treat shared action runtime as a first-class feature owner instead of
rebuilding it per surface. The overview shell and dashboard recent-alerts panel
must both compose
`frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts` for
acknowledge/restore behavior rather than keeping duplicate API and notification
logic inline in `useAlertOverviewState.ts` or
`frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx`.
The updates settings surface now follows the same presentation-owner rule.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the
top-level settings shell, while

View file

@ -236,7 +236,11 @@ That route shell now also composes the recent-alerts widget directly from the
alert-owned `frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx`
surface instead of via a dashboard-panels-local alert implementation, so the
dashboard route stays storage/recovery-owned while alert widget runtime remains
owned by the alerts subsystem.
owned by the alerts subsystem. That route handoff must stay thin: the dashboard
page should pass the live alert list into `RecentAlertsPanel` and let the
alert-owned surface derive its own summary and acknowledgement state instead of
rebuilding alert summary counts or alert-action runtime inside the
storage/recovery-governed dashboard route.
The shared recovery type contract must be pinned the same way:
`frontend-modern/src/types/recovery.ts` must stay on the explicit
`recovery-product-surface` proof path instead of riding indirectly on route or

View file

@ -1,9 +1,7 @@
import { For, Show, createMemo, createSignal } from 'solid-js';
import { For, Show, createMemo } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { ALERTS_OVERVIEW_PATH, AI_PATROL_PATH } from '@/routing/resourceLinks';
import { AlertsAPI } from '@/api/alerts';
import { formatRelativeTime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications';
import {
ALERTS_EMPTY_STATE,
getDashboardAlertSummaryText,
@ -13,15 +11,14 @@ import {
getAlertSeverityCompactLabel,
} from '@/utils/alertSeverityPresentation';
import type { Alert } from '@/types/api';
import { getCanonicalAlertId } from '@/features/alerts/identity';
import { useAlertAcknowledgementState } from '@/features/alerts/useAlertAcknowledgementState';
import BellIcon from 'lucide-solid/icons/bell';
const MAX_SHOWN = 8;
interface RecentAlertsPanelProps {
alerts: Alert[];
criticalCount: number;
warningCount: number;
totalCount: number;
}
function sortByStartTimeDesc(alerts: Alert[]): Alert[] {
@ -35,50 +32,32 @@ function sortByStartTimeDesc(alerts: Alert[]): Alert[] {
}
export function RecentAlertsPanel(props: RecentAlertsPanelProps) {
const recent = createMemo(() => sortByStartTimeDesc(props.alerts).slice(0, MAX_SHOWN));
const [ackLoading, setAckLoading] = createSignal<string | null>(null);
const [ackAllLoading, setAckAllLoading] = createSignal(false);
const unackedAlerts = createMemo(() => props.alerts.filter((a) => !a.acknowledged));
const {
effectiveAlerts,
unacknowledgedAlerts,
processingAlerts,
bulkAckProcessing,
handleAlertAcknowledgement,
handleBulkAcknowledge,
} = useAlertAcknowledgementState({
alerts: () => props.alerts,
});
const recent = createMemo(() => sortByStartTimeDesc(effectiveAlerts()).slice(0, MAX_SHOWN));
const activeCriticalCount = createMemo(
() =>
effectiveAlerts().filter((alert) => !alert.acknowledged && alert.level === 'critical').length,
);
const activeWarningCount = createMemo(
() =>
effectiveAlerts().filter((alert) => !alert.acknowledged && alert.level === 'warning').length,
);
const alertSummaryText = createMemo(() =>
getDashboardAlertSummaryText({
activeCritical: props.criticalCount,
activeWarning: props.warningCount,
activeCritical: activeCriticalCount(),
activeWarning: activeWarningCount(),
}),
);
const handleAck = async (alert: Alert) => {
setAckLoading(alert.id);
try {
await AlertsAPI.acknowledge(alert.id);
notificationStore.success('Alert acknowledged');
} catch (err) {
notificationStore.error((err as Error).message || 'Failed to acknowledge');
} finally {
setAckLoading(null);
}
};
const handleAckAll = async () => {
const ids = unackedAlerts().map((a) => a.id);
if (ids.length === 0) return;
setAckAllLoading(true);
try {
const result = await AlertsAPI.bulkAcknowledge(ids);
const failCount = result.results.filter((r) => !r.success).length;
if (failCount === 0) {
notificationStore.success(`${ids.length} alert${ids.length !== 1 ? 's' : ''} acknowledged`);
} else {
notificationStore.error(`${failCount} of ${ids.length} alerts failed to acknowledge`);
}
} catch (err) {
notificationStore.error((err as Error).message || 'Failed to acknowledge alerts');
} finally {
setAckAllLoading(false);
}
};
return (
<Card padding="none" tone="default" class="border-border-subtle overflow-hidden">
<div class="px-4 py-3 flex items-center justify-between gap-2">
@ -87,14 +66,16 @@ export function RecentAlertsPanel(props: RecentAlertsPanelProps) {
<h2 class="text-sm font-semibold text-base-content">Alerts</h2>
</div>
<div class="flex items-center gap-2">
<Show when={unackedAlerts().length > 1}>
<Show when={unacknowledgedAlerts().length > 1}>
<button
type="button"
onClick={handleAckAll}
disabled={ackAllLoading()}
onClick={() => {
void handleBulkAcknowledge();
}}
disabled={bulkAckProcessing()}
class="text-[10px] font-medium text-base-content bg-surface-alt hover:bg-surface-hover disabled:opacity-50 px-2 py-0.5 rounded transition-colors"
>
{ackAllLoading() ? 'Acking...' : 'Ack All'}
{bulkAckProcessing() ? 'Acking...' : 'Ack All'}
</button>
</Show>
<a
@ -140,11 +121,13 @@ export function RecentAlertsPanel(props: RecentAlertsPanelProps) {
<Show when={!alert.acknowledged}>
<button
type="button"
onClick={() => handleAck(alert)}
disabled={ackLoading() === alert.id}
onClick={() => {
void handleAlertAcknowledgement(alert);
}}
disabled={processingAlerts().has(getCanonicalAlertId(alert))}
class="shrink-0 px-1.5 py-0.5 text-[10px] font-medium text-base-content bg-surface-alt hover:bg-surface-hover disabled:opacity-50 rounded transition-colors"
>
{ackLoading() === alert.id ? '...' : 'Ack'}
{processingAlerts().has(getCanonicalAlertId(alert)) ? '...' : 'Ack'}
</button>
</Show>
</li>

View file

@ -1,8 +1,31 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertsAPI } from '@/api/alerts';
import { notificationStore } from '@/stores/notifications';
import { RecentAlertsPanel } from '../RecentAlertsPanel';
import type { Alert } from '@/types/api';
vi.mock('@/api/alerts', () => ({
AlertsAPI: {
acknowledge: vi.fn(),
bulkAcknowledge: vi.fn(),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
},
}));
function makeAlert(overrides: Partial<Alert> = {}): Alert {
return {
id: 'alert-1',
@ -20,14 +43,16 @@ function makeAlert(overrides: Partial<Alert> = {}): Alert {
}
describe('RecentAlertsPanel', () => {
beforeEach(() => {
vi.mocked(AlertsAPI.acknowledge).mockReset();
vi.mocked(AlertsAPI.bulkAcknowledge).mockReset();
vi.mocked(notificationStore.success).mockReset();
vi.mocked(notificationStore.error).mockReset();
});
it('renders summary counts when alerts exist', () => {
render(() => (
<RecentAlertsPanel
alerts={[makeAlert(), makeAlert({ id: 'alert-2', level: 'warning' })]}
criticalCount={1}
warningCount={1}
totalCount={2}
/>
<RecentAlertsPanel alerts={[makeAlert(), makeAlert({ id: 'alert-2', level: 'warning' })]} />
));
expect(screen.getByText(/critical ·/)).toBeInTheDocument();
@ -35,10 +60,43 @@ describe('RecentAlertsPanel', () => {
});
it('renders empty state when there are no active alerts', () => {
render(() => (
<RecentAlertsPanel alerts={[]} criticalCount={0} warningCount={0} totalCount={0} />
));
render(() => <RecentAlertsPanel alerts={[]} />);
expect(screen.getByText('No active alerts')).toBeInTheDocument();
});
it('routes single acknowledge actions through the shared alert acknowledgement owner', async () => {
vi.mocked(AlertsAPI.acknowledge).mockResolvedValue(undefined as never);
render(() => (
<RecentAlertsPanel alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]} />
));
await fireEvent.click(screen.getAllByText('Ack')[0]);
await waitFor(() => {
expect(AlertsAPI.acknowledge).toHaveBeenCalledWith('alert-1');
});
expect(notificationStore.success).toHaveBeenCalledWith('Alert acknowledged');
});
it('routes bulk acknowledge actions through the shared alert acknowledgement owner', async () => {
vi.mocked(AlertsAPI.bulkAcknowledge).mockResolvedValue({
results: [
{ alertIdentifier: 'alert-1', success: true },
{ alertIdentifier: 'alert-2', success: true },
],
} as never);
render(() => (
<RecentAlertsPanel alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]} />
));
await fireEvent.click(screen.getByText('Ack All'));
await waitFor(() => {
expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['alert-1', 'alert-2']);
});
expect(notificationStore.success).toHaveBeenCalledWith('Acknowledged 2 alerts.');
});
});

View file

@ -0,0 +1,136 @@
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 { useAlertAcknowledgementState } from '../useAlertAcknowledgementState';
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, acknowledged = false): Alert {
return {
id,
type: 'cpu',
level: 'warning',
resourceId: `vm-${id}`,
resourceName: `VM ${id}`,
node: 'node-1',
message: `CPU high on ${id}`,
startTime: '2026-03-22T11:00:00Z',
acknowledged,
} as Alert;
}
describe('useAlertAcknowledgementState', () => {
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 shared alert acknowledge, restore, and bulk-ack runtime with optimistic state', async () => {
const [alerts] = createSignal<Alert[]>([
makeAlert('alert-1'),
makeAlert('alert-2', true),
makeAlert('alert-3'),
]);
const updateAlert = vi.fn();
vi.mocked(AlertsAPI.acknowledge).mockResolvedValue(undefined as never);
vi.mocked(AlertsAPI.unacknowledge).mockResolvedValue(undefined as never);
vi.mocked(AlertsAPI.bulkAcknowledge).mockResolvedValue({
results: [
{ alertIdentifier: 'alert-1', success: true },
{ alertIdentifier: 'alert-3', success: false },
],
} as never);
const { result } = renderHook(() =>
useAlertAcknowledgementState({
alerts,
updateAlert,
allowRestore: true,
}),
);
expect(result.unacknowledgedAlerts().map((alert) => alert.id)).toEqual(['alert-1', 'alert-3']);
await result.handleAlertAcknowledgement(alerts()[0]);
expect(AlertsAPI.acknowledge).toHaveBeenCalledWith('alert-1');
expect(updateAlert).toHaveBeenCalledWith(
'alert-1',
expect.objectContaining({ acknowledged: true }),
);
expect(notificationStore.success).toHaveBeenCalledWith('Alert acknowledged');
expect(result.unacknowledgedAlerts().map((alert) => alert.id)).toEqual(['alert-3']);
vi.advanceTimersByTime(1500);
expect(result.processingAlerts().has('alert-1')).toBe(false);
await result.handleAlertAcknowledgement(alerts()[1]);
expect(AlertsAPI.unacknowledge).toHaveBeenCalledWith('alert-2');
expect(updateAlert).toHaveBeenCalledWith(
'alert-2',
expect.objectContaining({ acknowledged: false }),
);
expect(notificationStore.success).toHaveBeenCalledWith('Alert restored');
await result.handleBulkAcknowledge();
expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['alert-2', 'alert-3']);
expect(notificationStore.error).toHaveBeenCalledWith('Failed to acknowledge 1 alert.');
});
it('supports optimistic acknowledgement even without an upstream update callback', async () => {
const [alerts] = createSignal<Alert[]>([makeAlert('alert-1'), makeAlert('alert-2')]);
vi.mocked(AlertsAPI.bulkAcknowledge).mockResolvedValue({
results: [
{ alertIdentifier: 'alert-1', success: true },
{ alertIdentifier: 'alert-2', success: true },
],
} as never);
const { result } = renderHook(() =>
useAlertAcknowledgementState({
alerts,
}),
);
await result.handleBulkAcknowledge();
expect(result.unacknowledgedAlerts()).toHaveLength(0);
expect(notificationStore.success).toHaveBeenCalledWith('Acknowledged 2 alerts.');
});
});

View file

@ -109,7 +109,7 @@ describe('useAlertOverviewState', () => {
await result.handleBulkAcknowledge();
expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['warning', 'old']);
expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['old']);
expect(updateAlert).toHaveBeenCalledWith(
'warning',
expect.objectContaining({ acknowledged: true }),

View file

@ -0,0 +1,192 @@
import { createEffect, 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';
export interface UseAlertAcknowledgementStateProps {
alerts: Accessor<Alert[]>;
updateAlert?: (alertIdentifier: string, updates: Partial<Alert>) => void;
allowRestore?: boolean;
}
type AlertAcknowledgementOverride = Pick<Alert, 'acknowledged' | 'ackTime' | 'ackUser'>;
export function useAlertAcknowledgementState(props: UseAlertAcknowledgementStateProps) {
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
const [acknowledgementOverrides, setAcknowledgementOverrides] = createSignal<
Record<string, AlertAcknowledgementOverride>
>({});
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();
});
createEffect(() => {
const alertsByIdentifier = new Map(
props.alerts().map((alert) => [getCanonicalAlertId(alert), alert] as const),
);
setAcknowledgementOverrides((previous) => {
let changed = false;
const next = { ...previous };
for (const [alertIdentifier, override] of Object.entries(previous)) {
const alert = alertsByIdentifier.get(alertIdentifier);
if (!alert || alert.acknowledged === override.acknowledged) {
delete next[alertIdentifier];
changed = true;
}
}
return changed ? next : previous;
});
});
const effectiveAlerts = createMemo(() =>
props.alerts().map((alert) => {
const override = acknowledgementOverrides()[getCanonicalAlertId(alert)];
return override ? { ...alert, ...override } : alert;
}),
);
const unacknowledgedAlerts = createMemo(() =>
effectiveAlerts().filter((alert) => !alert.acknowledged),
);
const applyAlertUpdate = (alertIdentifier: string, updates: AlertAcknowledgementOverride) => {
setAcknowledgementOverrides((previous) => ({
...previous,
[alertIdentifier]: updates,
}));
props.updateAlert?.(alertIdentifier, updates);
};
const releaseAlertProcessing = (alertIdentifier: string) => {
clearProcessingReleaseTimer(alertIdentifier);
const timer = setTimeout(() => {
processingReleaseTimers.delete(alertIdentifier);
setProcessingAlerts((previous) => {
const next = new Set(previous);
next.delete(alertIdentifier);
return next;
});
}, 1500);
processingReleaseTimers.set(alertIdentifier, timer);
};
const handleAlertAcknowledgement = async (alert: Alert) => {
const alertIdentifier = getCanonicalAlertId(alert);
if (processingAlerts().has(alertIdentifier)) {
return;
}
const currentAlert =
effectiveAlerts().find((entry) => getCanonicalAlertId(entry) === alertIdentifier) ?? alert;
const wasAcknowledged = currentAlert.acknowledged;
if (wasAcknowledged && !props.allowRestore) {
return;
}
setProcessingAlerts((previous) => new Set(previous).add(alertIdentifier));
try {
if (wasAcknowledged) {
await AlertsAPI.unacknowledge(alertIdentifier);
applyAlertUpdate(alertIdentifier, {
acknowledged: false,
ackTime: undefined,
ackUser: undefined,
});
notificationStore.success('Alert restored');
} else {
await AlertsAPI.acknowledge(alertIdentifier);
applyAlertUpdate(alertIdentifier, {
acknowledged: true,
ackTime: new Date().toISOString(),
ackUser: undefined,
});
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 pendingAlerts = unacknowledgedAlerts();
if (pendingAlerts.length === 0) {
return;
}
setBulkAckProcessing(true);
try {
const result = await AlertsAPI.bulkAcknowledge(
pendingAlerts.map((alert) => getCanonicalAlertId(alert)),
);
const acknowledgedAt = new Date().toISOString();
const successes = result.results.filter((entry) => entry.success);
const failures = result.results.filter((entry) => !entry.success);
successes.forEach((entry) => {
applyAlertUpdate(entry.alertIdentifier, {
acknowledged: true,
ackTime: acknowledgedAt,
ackUser: undefined,
});
});
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 {
effectiveAlerts,
unacknowledgedAlerts,
processingAlerts,
bulkAckProcessing,
handleAlertAcknowledgement,
handleBulkAcknowledge,
};
}

View file

@ -1,13 +1,9 @@
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';
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
export interface UseAlertOverviewStateProps {
activeAlerts: Accessor<Record<string, Alert>>;
@ -17,29 +13,28 @@ export interface UseAlertOverviewStateProps {
}
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);
};
const activeAlerts = createMemo(() => Object.values(props.activeAlerts()));
const {
effectiveAlerts,
unacknowledgedAlerts,
processingAlerts,
bulkAckProcessing,
handleAlertAcknowledgement,
handleBulkAcknowledge,
} = useAlertAcknowledgementState({
alerts: activeAlerts,
updateAlert: props.updateAlert,
allowRestore: true,
});
onCleanup(() => {
clearInterval(tickInterval);
processingReleaseTimers.forEach((timer) => clearTimeout(timer));
processingReleaseTimers.clear();
});
const alertStats = createMemo(() => {
const alerts = Object.values(props.activeAlerts());
const alerts = effectiveAlerts();
return {
active: alerts.filter((alert) => !alert.acknowledged).length,
acknowledged: alerts.filter((alert) => alert.acknowledged).length,
@ -52,7 +47,7 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
});
const filteredAlerts = createMemo(() =>
Object.values(props.activeAlerts())
effectiveAlerts()
.filter((alert) => props.showAcknowledged() || !alert.acknowledged)
.sort((a, b) => {
if (a.acknowledged !== b.acknowledged) {
@ -62,104 +57,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
}),
);
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,

View file

@ -121,14 +121,7 @@ export default function Dashboard() {
/>
);
case 'alerts':
return (
<RecentAlertsPanel
alerts={alertsList()}
criticalCount={overview().alerts.activeCritical}
warningCount={overview().alerts.activeWarning}
totalCount={overview().alerts.total}
/>
);
return <RecentAlertsPanel alerts={alertsList()} />;
case 'recovery':
return <DashboardRecoveryStatusPanel recovery={recoverySummary()} />;
case 'storage':

View file

@ -3,6 +3,7 @@ import alertsPageSource from '@/pages/Alerts.tsx?raw';
import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw';
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.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';
@ -11,6 +12,7 @@ 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 recentAlertsPanelSource from '@/components/Alerts/RecentAlertsPanel.tsx?raw';
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw';
import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw';
@ -228,9 +230,17 @@ describe('tab path helpers', () => {
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(alertOverviewStateSource).toContain('useAlertAcknowledgementState');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.acknowledge');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.unacknowledge');
expect(alertAcknowledgementStateSource).toContain('export function useAlertAcknowledgementState');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.bulkAcknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.acknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.unacknowledge');
expect(recentAlertsPanelSource).toContain('useAlertAcknowledgementState');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.acknowledge');
expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions');
expect(alertThresholdsTabSource).toContain('ThresholdsTable');
expect(thresholdsTableSource).toContain(

View file

@ -127,6 +127,9 @@ describe('Dashboard page module contract', () => {
it('routes the alerts dashboard widget through the alert-owned surface', () => {
expect(dashboardPageSource).toContain("from '@/components/Alerts/RecentAlertsPanel'");
expect(dashboardPageSource).toContain('return <RecentAlertsPanel alerts={alertsList()} />;');
expect(dashboardPageSource).not.toContain('criticalCount={overview().alerts.activeCritical}');
expect(dashboardPageSource).not.toContain('warningCount={overview().alerts.activeWarning}');
});
it('routes dashboard overview panels through the dashboard overview feature owner', () => {

View file

@ -327,6 +327,7 @@ import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw';
import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw';
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.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';
@ -3657,9 +3658,17 @@ describe('frontend resource type boundaries', () => {
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(alertOverviewStateSource).toContain('useAlertAcknowledgementState');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.acknowledge');
expect(alertOverviewStateSource).not.toContain('AlertsAPI.unacknowledge');
expect(alertAcknowledgementStateSource).toContain('export function useAlertAcknowledgementState');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.bulkAcknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.acknowledge');
expect(alertAcknowledgementStateSource).toContain('AlertsAPI.unacknowledge');
expect(recentAlertsPanelSource).toContain('useAlertAcknowledgementState');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.acknowledge');
expect(alertOverviewPresentationSource).toContain(
'export function getAlertTimelineLoadingState',
);