diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 89e48189e..605961118 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 793c073c3..07da76924 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0c53bca9b..1b0f5c3a2 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx b/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx index 51488610c..f2c03e6fc 100644 --- a/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx +++ b/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx @@ -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(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 (
@@ -87,14 +66,16 @@ export function RecentAlertsPanel(props: RecentAlertsPanelProps) {

Alerts

- 1}> + 1}> diff --git a/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx b/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx index 8c0e9a507..4aec8acff 100644 --- a/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx @@ -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 { return { id: 'alert-1', @@ -20,14 +43,16 @@ function makeAlert(overrides: Partial = {}): 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(() => ( - + )); expect(screen.getByText(/critical ยท/)).toBeInTheDocument(); @@ -35,10 +60,43 @@ describe('RecentAlertsPanel', () => { }); it('renders empty state when there are no active alerts', () => { - render(() => ( - - )); + render(() => ); 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(() => ( + + )); + + 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(() => ( + + )); + + await fireEvent.click(screen.getByText('Ack All')); + + await waitFor(() => { + expect(AlertsAPI.bulkAcknowledge).toHaveBeenCalledWith(['alert-1', 'alert-2']); + }); + expect(notificationStore.success).toHaveBeenCalledWith('Acknowledged 2 alerts.'); + }); }); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertAcknowledgementState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertAcknowledgementState.test.tsx new file mode 100644 index 000000000..16ccacbe5 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/useAlertAcknowledgementState.test.tsx @@ -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([ + 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([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.'); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx index 75fc27d40..8ee138ac7 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx @@ -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 }), diff --git a/frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts b/frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts new file mode 100644 index 000000000..9482d6690 --- /dev/null +++ b/frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts @@ -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; + updateAlert?: (alertIdentifier: string, updates: Partial) => void; + allowRestore?: boolean; +} + +type AlertAcknowledgementOverride = Pick; + +export function useAlertAcknowledgementState(props: UseAlertAcknowledgementStateProps) { + const [processingAlerts, setProcessingAlerts] = createSignal>(new Set()); + const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false); + const [acknowledgementOverrides, setAcknowledgementOverrides] = createSignal< + Record + >({}); + const processingReleaseTimers = new Map>(); + + 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, + }; +} diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index f67d4b089..5ac061c67 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -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>; @@ -17,29 +13,28 @@ export interface UseAlertOverviewStateProps { } export function useAlertOverviewState(props: UseAlertOverviewStateProps) { - const [processingAlerts, setProcessingAlerts] = createSignal>(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>(); - - 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, diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index 39b3b4dfe..34d4e1826 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -121,14 +121,7 @@ export default function Dashboard() { /> ); case 'alerts': - return ( - - ); + return ; case 'recovery': return ; case 'storage': diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 817833eaa..de0090210 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -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( diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index db283fe66..1da832d44 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -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 ;'); + 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', () => { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index e1ad64e9a..f9d37172d 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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', );