feat(alerts): show notification delivery status on active alert cards

Each active alert card now answers "did this notify, and if not, why
not?" inline: notified time, cooldown with next eligible time, quiet
hours with the replay time, and attention-toned lines for held states
the user may not expect (rate limit, flapping, suppression window,
notifications off / not turned on). Backed by the bulk delivery
diagnosis endpoint — one request per overview refresh, silent degrade
when unavailable. Acknowledged alerts keep their badge and show no
line.

Surfaces the previously unconsumed AlertDeliveryDiagnosis projection
(coverage gap alert-engine-suppression-observability, Phase 0).
This commit is contained in:
rcourtman
2026-08-26 21:05:45 +01:00
parent 515aa319e8
commit 32e620a6c3
7 changed files with 399 additions and 3 deletions
+8 -1
View File
@@ -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<AlertDeliveryDiagnosis[]> {
const diagnoses = (await apiFetchJSON(
`${this.baseUrl}/delivery-diagnosis`,
)) as AlertDeliveryDiagnosis[];
return arrayOrEmpty<AlertDeliveryDiagnosis>(diagnoses);
}
static async getIncidentTimeline(
alertIdentifier: string,
startedAt?: string,
@@ -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) {
: '%'}
</span>
</Show>
<Show when={deliveryStatusLine()}>
<span
class={
deliveryStatusLine()?.tone === 'attention'
? 'text-xs text-amber-600 dark:text-amber-400'
: 'text-xs text-muted'
}
title={deliveryDiagnosis()?.message}
>
{deliveryStatusLine()?.label}
</span>
</Show>
</div>
</div>
</div>
@@ -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<string, unknown>) => props.children,
}));
const getDeliveryDiagnoses = vi.fn<() => Promise<AlertDeliveryDiagnosis[]>>();
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>,
): 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<string, unknown> = {}) {
return {
overrides: [] as never[],
activeAlerts: {} as Record<string, Alert>,
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<string, Alert> = { a1: makeAlert('a1') };
getDeliveryDiagnoses.mockResolvedValue([
makeDiagnosis('a1', { status: 'suppressed', reason: 'notifications_disabled' }),
]);
render(() => <OverviewTab {...defaultProps({ activeAlerts })} />);
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<string, Alert> = { a1: makeAlert('a1') };
getDeliveryDiagnoses.mockRejectedValue(new Error('boom'));
render(() => <OverviewTab {...defaultProps({ activeAlerts })} />);
await waitFor(() => {
expect(getDeliveryDiagnoses).toHaveBeenCalled();
});
expect(screen.queryByText('Notifications are turned off')).toBeNull();
expect(screen.getByText('High CPU on VM a1')).toBeTruthy();
});
});
@@ -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>): 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();
});
});
@@ -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;
}
};
@@ -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<string, AlertDeliveryDiagnosis>
>({});
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<string, AlertDeliveryDiagnosis> = {};
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,
+29
View File
@@ -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;