mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-09 19:32:24 +00:00
Extract shared alert acknowledgement owner
This commit is contained in:
parent
24c724f0dd
commit
bd362a28da
13 changed files with 505 additions and 204 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue