diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index fc4c5426b..ceb15a98b 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -1,4 +1,4 @@ -import type { Alert, Incident } from '@/types/api'; +import type { Alert, AlertDeliveryDiagnosis, Incident } from '@/types/api'; import type { AlertConfig } from '@/types/alerts'; import { apiFetchJSON } from '@/utils/apiClient'; import { arrayOrEmpty } from './responseUtils'; @@ -30,6 +30,13 @@ export class AlertsAPI { return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`); } + static async getDeliveryDiagnoses(): Promise { + const diagnoses = (await apiFetchJSON( + `${this.baseUrl}/delivery-diagnosis`, + )) as AlertDeliveryDiagnosis[]; + return arrayOrEmpty(diagnoses); + } + static async getIncidentTimeline( alertIdentifier: string, startedAt?: string, diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 6b513f4bc..3d7575c5e 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -18,6 +18,7 @@ import { } from '@/utils/alertOverviewPresentation'; import { alertTypeDisplayLabel } from './helpers'; +import { describeAlertDeliveryStatus } from './deliveryDiagnosisPresentation'; import { getCanonicalAlertId } from './identity'; import type { AlertIncidentTimelineState } from './useAlertIncidentTimelineState'; import type { AlertOverviewState } from './useAlertOverviewState'; @@ -38,6 +39,10 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { props.state.processingAlerts().has(alertKey()), ); + const deliveryDiagnosis = () => props.state.deliveryDiagnoses()[alertKey()]; + const deliveryStatusLine = () => + describeAlertDeliveryStatus(deliveryDiagnosis(), props.alert.acknowledged); + const resourceLink = (): string => { const rid = props.alert.resourceId ?? ''; const resourceType = @@ -153,6 +158,18 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { : '%'} + + + {deliveryStatusLine()?.label} + + diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx new file mode 100644 index 000000000..995a149a4 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n'; +import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; + +vi.mock('@solidjs/router', () => ({ + useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }), + A: (props: Record) => props.children, +})); + +const getDeliveryDiagnoses = vi.fn<() => Promise>(); + +vi.mock('@/api/alerts', () => ({ + AlertsAPI: { + get getDeliveryDiagnoses() { + return getDeliveryDiagnoses; + }, + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { error: vi.fn() }, +})); + +vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ + InvestigateAlertButton: () => null, +})); + +import { OverviewTab } from '../OverviewTab'; + +function makeAlert(id: string, ack = false): Alert { + return { + id, + resourceId: `vm-${id}`, + resourceName: `VM ${id}`, + type: 'cpu', + level: 'warning', + message: `High CPU on VM ${id}`, + startTime: new Date().toISOString(), + acknowledged: ack, + node: 'node1', + } as Alert; +} + +function makeDiagnosis( + id: string, + overrides: Partial, +): AlertDeliveryDiagnosis { + return { + alertIdentifier: id, + alertId: id, + trackingKey: `vm-${id}/cpu`, + status: 'would_send', + reason: 'ready', + message: 'Alert delivery is currently eligible for notification delivery.', + alertType: 'cpu', + level: 'warning', + notificationsEnabled: true, + activationState: 'active', + cooldownMinutes: 5, + maxAlertsHour: 10, + recentAlertsInHour: 0, + flappingActive: false, + flappingHistoryInWindow: 0, + flappingThreshold: 5, + flappingWindowSeconds: 300, + ...overrides, + }; +} + +function defaultProps(overrides: Record = {}) { + return { + overrides: [] as never[], + activeAlerts: {} as Record, + updateAlert: vi.fn(), + showQuickTip: () => false, + dismissQuickTip: vi.fn(), + showAcknowledged: () => true, + setShowAcknowledged: vi.fn(), + alertsDisabled: () => false, + ...overrides, + }; +} + +describe('OverviewTab delivery status line', () => { + beforeEach(() => { + setActiveLocale(DEFAULT_LOCALE); + getDeliveryDiagnoses.mockReset(); + }); + + afterEach(() => { + cleanup(); + setActiveLocale(DEFAULT_LOCALE); + }); + + it('renders held-notification status from the bulk diagnosis endpoint', async () => { + const activeAlerts: Record = { a1: makeAlert('a1') }; + getDeliveryDiagnoses.mockResolvedValue([ + makeDiagnosis('a1', { status: 'suppressed', reason: 'notifications_disabled' }), + ]); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Notifications are turned off')).toBeTruthy(); + }); + expect(getDeliveryDiagnoses).toHaveBeenCalled(); + }); + + it('renders no delivery line when the diagnosis fetch fails', async () => { + const activeAlerts: Record = { a1: makeAlert('a1') }; + getDeliveryDiagnoses.mockRejectedValue(new Error('boom')); + + render(() => ); + + await waitFor(() => { + expect(getDeliveryDiagnoses).toHaveBeenCalled(); + }); + expect(screen.queryByText('Notifications are turned off')).toBeNull(); + expect(screen.getByText('High CPU on VM a1')).toBeTruthy(); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts new file mode 100644 index 000000000..ecc7be09c --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import type { AlertDeliveryDiagnosis } from '@/types/api'; + +import { describeAlertDeliveryStatus } from '../deliveryDiagnosisPresentation'; + +const baseDiagnosis = (overrides: Partial): AlertDeliveryDiagnosis => ({ + alertIdentifier: 'a1', + alertId: 'a1', + trackingKey: 'node/a1/cpu', + status: 'would_send', + reason: 'ready', + message: 'Alert delivery is currently eligible for notification delivery.', + alertType: 'cpu', + level: 'warning', + notificationsEnabled: true, + activationState: 'active', + cooldownMinutes: 5, + maxAlertsHour: 10, + recentAlertsInHour: 0, + flappingActive: false, + flappingHistoryInWindow: 0, + flappingThreshold: 5, + flappingWindowSeconds: 300, + ...overrides, +}); + +describe('describeAlertDeliveryStatus', () => { + it('returns null without a diagnosis', () => { + expect(describeAlertDeliveryStatus(undefined, false)).toBeNull(); + }); + + it('returns null for acknowledged alerts regardless of status', () => { + const diagnosis = baseDiagnosis({ status: 'suppressed', reason: 'cooldown' }); + expect(describeAlertDeliveryStatus(diagnosis, true)).toBeNull(); + }); + + it('shows the notified time when eligible and already notified', () => { + const diagnosis = baseDiagnosis({ lastNotified: '2026-08-26T10:15:00Z' }); + const line = describeAlertDeliveryStatus(diagnosis, false); + expect(line?.tone).toBe('muted'); + expect(line?.label).toMatch(/^Notified /); + }); + + it('shows pending when eligible but never notified', () => { + const line = describeAlertDeliveryStatus(baseDiagnosis({}), false); + expect(line).toEqual({ label: 'Notification pending', tone: 'muted' }); + }); + + it('shows quiet hours with the replay time for deferred alerts', () => { + const diagnosis = baseDiagnosis({ + status: 'deferred', + reason: 'quiet_hours:performance', + quietHoursReplayAt: '2026-08-27T07:00:00Z', + }); + const line = describeAlertDeliveryStatus(diagnosis, false); + expect(line?.tone).toBe('muted'); + expect(line?.label).toMatch(/^Quiet hours — notifies /); + }); + + it('treats cooldown as healthy and shows the next eligible time', () => { + const diagnosis = baseDiagnosis({ + status: 'suppressed', + reason: 'cooldown', + lastNotified: '2026-08-26T10:15:00Z', + nextEligibleAt: '2026-08-26T10:20:00Z', + }); + const line = describeAlertDeliveryStatus(diagnosis, false); + expect(line?.tone).toBe('muted'); + expect(line?.label).toMatch(/^Notified .* — next /); + }); + + it.each([ + ['rate_limited', 'Hourly notification limit reached'], + ['flapping', 'Flapping — notifications paused'], + ['notifications_disabled', 'Notifications are turned off'], + ['notifications_inactive', 'Notification delivery not turned on'], + ] as const)('marks %s with the attention tone', (reason, label) => { + const diagnosis = baseDiagnosis({ status: 'suppressed', reason }); + expect(describeAlertDeliveryStatus(diagnosis, false)).toEqual({ label, tone: 'attention' }); + }); + + it('shows the pause end time for suppression windows', () => { + const diagnosis = baseDiagnosis({ + status: 'suppressed', + reason: 'suppression_window', + suppressedUntil: '2026-08-26T11:00:00Z', + }); + const line = describeAlertDeliveryStatus(diagnosis, false); + expect(line?.tone).toBe('attention'); + expect(line?.label).toMatch(/^Notifications paused until /); + }); + + it('stays silent on unknown reasons', () => { + const diagnosis = baseDiagnosis({ status: 'suppressed', reason: 'future_reason' }); + expect(describeAlertDeliveryStatus(diagnosis, false)).toBeNull(); + }); +}); diff --git a/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts new file mode 100644 index 000000000..3df8f6365 --- /dev/null +++ b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts @@ -0,0 +1,75 @@ +import type { AlertDeliveryDiagnosis } from '@/types/api'; + +export interface AlertDeliveryStatusLine { + label: string; + // 'attention' marks held notifications the user may not expect; 'muted' + // marks healthy or user-chosen states. + tone: 'muted' | 'attention'; +} + +const formatShortTime = (value?: string): string | null => { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +// describeAlertDeliveryStatus turns a delivery diagnosis into the one-line +// notification status shown on the alert card. Returns null when the card +// should show no line (acknowledged alerts already carry a badge, and an +// unknown reason is better silent than wrong). +export const describeAlertDeliveryStatus = ( + diagnosis: AlertDeliveryDiagnosis | undefined, + acknowledged: boolean, +): AlertDeliveryStatusLine | null => { + if (!diagnosis || acknowledged) return null; + + const reason = (diagnosis.reason || '').split(':')[0]; + + if (diagnosis.status === 'would_send') { + const notifiedAt = formatShortTime(diagnosis.lastNotified); + if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' }; + return { label: 'Notification pending', tone: 'muted' }; + } + + if (diagnosis.status === 'deferred') { + const replayAt = formatShortTime(diagnosis.quietHoursReplayAt); + return { + label: replayAt ? `Quiet hours — notifies ${replayAt}` : 'Quiet hours — notifies later', + tone: 'muted', + }; + } + + switch (reason) { + case 'acknowledged': + return null; + case 'cooldown': { + const notifiedAt = formatShortTime(diagnosis.lastNotified); + const nextAt = formatShortTime(diagnosis.nextEligibleAt); + if (notifiedAt && nextAt) { + return { label: `Notified ${notifiedAt} — next ${nextAt}`, tone: 'muted' }; + } + if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' }; + return { label: 'Waiting for cooldown', tone: 'muted' }; + } + case 'rate_limited': + return { label: 'Hourly notification limit reached', tone: 'attention' }; + case 'flapping': + return { label: 'Flapping — notifications paused', tone: 'attention' }; + case 'suppression_window': { + const until = formatShortTime(diagnosis.suppressedUntil); + return { + label: until ? `Notifications paused until ${until}` : 'Notifications paused', + tone: 'attention', + }; + } + case 'notifications_disabled': + return { label: 'Notifications are turned off', tone: 'attention' }; + case 'notifications_inactive': + return { label: 'Notification delivery not turned on', tone: 'attention' }; + case 'monitor_only': + return { label: 'Monitor-only — no notifications', tone: 'muted' }; + default: + return null; + } +}; diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index 32465f160..39119ab38 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -1,7 +1,8 @@ -import { createMemo, createSignal, onCleanup } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { Accessor } from 'solid-js'; -import type { Alert } from '@/types/api'; +import { AlertsAPI } from '@/api/alerts'; +import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; import type { Override } from './types'; import { useAlertAcknowledgementState } from './useAlertAcknowledgementState'; @@ -49,6 +50,47 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { clearInterval(tickInterval); }); + // Delivery diagnoses answer "did/will this alert notify?" per card. One + // bulk request covers every active alert; a failed refresh keeps the last + // snapshot and the card simply shows no delivery line for unknown alerts. + const [deliveryDiagnoses, setDeliveryDiagnoses] = createSignal< + Record + >({}); + let diagnosisStateDisposed = false; + onCleanup(() => { + diagnosisStateDisposed = true; + }); + const refreshDeliveryDiagnoses = async () => { + if (activeAlerts().length === 0) { + setDeliveryDiagnoses({}); + return; + } + try { + const list = await AlertsAPI.getDeliveryDiagnoses(); + if (diagnosisStateDisposed) return; + const next: Record = {}; + for (const diagnosis of list) { + next[diagnosis.alertIdentifier || diagnosis.alertId] = diagnosis; + } + setDeliveryDiagnoses(next); + } catch { + // Silent degrade: no diagnosis, no delivery line. + } + }; + const activeAlertIdsKey = createMemo(() => + activeAlerts() + .map((alert) => alert.id) + .sort() + .join('\n'), + ); + createEffect(() => { + // Refresh when the active alert set changes and on the shared minute + // tick, so time-based holds (cooldown, quiet hours) stay current. + activeAlertIdsKey(); + tick(); + void refreshDeliveryDiagnoses(); + }); + const alertStats = createMemo(() => { const alerts = effectiveAlerts(); const recent = alerts.filter((alert) => { @@ -108,6 +150,8 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { unacknowledgedAlerts, processingAlerts, bulkAckProcessing, + deliveryDiagnoses, + refreshDeliveryDiagnoses, handleAlertAcknowledgement, handleBulkAcknowledge, handleGroupAcknowledge, diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index e057f0f6c..7f2a8c6e9 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -1328,6 +1328,35 @@ export interface ResolvedAlert extends Alert { resolvedTime: string; } +// Read-only projection of notification delivery policy for one active alert, +// from GET /api/alerts/delivery-diagnosis. +export interface AlertDeliveryDiagnosis { + alertIdentifier: string; + alertId: string; + trackingKey: string; + status: 'would_send' | 'deferred' | 'suppressed'; + reason: string; + message: string; + alertType: string; + level: 'warning' | 'critical'; + resourceId?: string; + resourceName?: string; + node?: string; + notificationsEnabled: boolean; + activationState: string; + cooldownMinutes: number; + maxAlertsHour: number; + recentAlertsInHour: number; + flappingActive: boolean; + flappingHistoryInWindow: number; + flappingThreshold: number; + flappingWindowSeconds: number; + lastNotified?: string; + nextEligibleAt?: string; + quietHoursReplayAt?: string; + suppressedUntil?: string; +} + export interface IncidentEvent { id: string; type: string;