mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user