mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Add a user-facing alert delivery log and honest test-send results
The delivery health verdict said when something was wrong; nothing showed what actually fired and where it went. Expose the queue's retained per-attempt audit rows as GET /api/notifications/delivery-log (newest first, retention-labeled, webhook secrets redacted from error text) and render them as a Recent delivery activity card on the alert destinations tab, with outcome badges, destination names, and failure classes. Audit rows now persist the normalized destination identity in a destination_id column; older rows fall back to their operational links. Test sends bypass both the queue and the activation gate, which is exactly how installs came to believe delivery worked while every real alert was suppressed (483 installs in the 08-18 telemetry read). Successful test responses now carry deliveryPaused: true whenever the manager is gated off, and the destinations UI warns instead of celebrating.
This commit is contained in:
parent
a5f1567172
commit
74873e2b55
26 changed files with 1603 additions and 40 deletions
|
|
@ -1,16 +1,28 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "fd35c547730a62ad7118de6cf898408f308d8317",
|
||||
"verified_at": "2026-08-20T16:29:56Z",
|
||||
"base_sha": "a5f1567172314003ee1b96b93090ffcbbe18e8f6",
|
||||
"verified_at": "2026-08-20T16:20:02Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/i18n/messages.de.ts"
|
||||
"frontend-modern/src/api/notifications.ts",
|
||||
"frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx",
|
||||
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
|
||||
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
|
||||
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts",
|
||||
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts",
|
||||
"frontend-modern/src/utils/alertDestinationsPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/i18n/messages.de.ts": "0adaabff61c0ed7fb9064ea05f9d83595ab673958249d6cb5b0e6bc212a3ca46"
|
||||
"frontend-modern/src/api/notifications.ts": "5bd796b6ec9222d6973a4a26b892b4fcfde11b2d9dd2e2ef705ad34531af0071",
|
||||
"frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "7e7e4c5da2a4ea8c267c546da0064806949573bc57a51cf7c5b5f5f8576826ef",
|
||||
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "0411429355fd78e75948df8ede39da75f9f9e3600840d9b98f7342d2adbfad7b",
|
||||
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "c88336576ec08b69271244947f7892cac0b77e9ec68fdf33a12fe0f130e4e29d",
|
||||
"frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts": "b9c959388a06ce146f3706cbc2b191449d4dd5380198d711ed15fdf06fdc48b9",
|
||||
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "48ca901ec9435d94a06de5e55bf05dbf254cfd23114cd2b31383909d1585237b",
|
||||
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "8a2e9446dfbdd77d46ef6a678ff2aa403741e3d296ac8ef67ba708c8940a9b2a"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/system-general"
|
||||
"/alerts/notifications"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -23,12 +35,19 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"German locale (pulseLocalePreference=de): Docker in Proxmox-LXCs card on the general settings panel renders the guestDocker toggle description with the corrected sentence break 'werden uebersprungen. Pulse erfasst nur Containerliste und Auslastung' in place of the semicolon the copy-style audit rejects",
|
||||
"Mobile viewport: the same description renders with no horizontal overflow (documentElement.scrollWidth == 375)",
|
||||
"Receipt re-pinned from base 23304bc01 to fd35c5477 during rebase: the staged messages.de.ts blob is byte-identical to the exercised content, and the new parent does not touch messages.de.ts"
|
||||
"Fresh-install destinations tab (activation pending): AlertDeliveryPausedCard at top, Recent delivery activity card at bottom with honest empty state naming the 7-day window and the test-sends-skip-the-queue caveat",
|
||||
"Delivery log with seeded audit rows served by the sidecar-built backend at GET /api/notifications/delivery-log: newest-first entries with Failed-retries-exhausted (red), Retrying (amber), Delivered (green) outcome badges",
|
||||
"Webhook destination id (webhook:<id>) resolved to the configured webhook name 'Ops Sink'; email entry labeled 'Email' with its opaque destination hash",
|
||||
"Failure detail line renders 'Authentication failure: HTTP 401 Unauthorized from https://hooks.example.test/notify?token=REDACTED' proving API-layer webhook secret redaction (seeded value was token=supersecret)",
|
||||
"Mobile viewport: card and badges wrap with no horizontal overflow (documentElement.scrollWidth == 375)",
|
||||
"No console errors on the destinations tab after load, test send, and delivery log refresh",
|
||||
"Receipt re-pinned during rebase onto a5f156717 (copy-fix replayed onto fd35c5477 canonical search work): every staged blob hash above is byte-identical to the exercised content, and neither upstream commit touches any of these files"
|
||||
],
|
||||
"interactions": [
|
||||
"Set localStorage pulseLocalePreference=de on the sidecar-built stack (backend :7811, vite :5487), navigated to /settings/system-general, and located the rendered description text via a DOM text walk at desktop width",
|
||||
"Resized to 375x812 and re-located the corrected text on the same panel"
|
||||
"Logged into the isolated sidecar-built stack (backend :7811, vite :5487), opened Alerts > Notifications",
|
||||
"Added webhook 'Ops Sink' -> http://127.0.0.1:18712/hook (allowlisted 127.0.0.1/32) and clicked Test: local sink logged the POST 200 and the UI raised the warning toast 'Test sent, but notification delivery is paused: real alerts are not being sent' instead of plain success, because the install's activation gate is pending",
|
||||
"Saved the webhook via Add Webhook and confirmed GET /api/notifications/webhooks returns it",
|
||||
"Seeded notification_audit rows (sent email, retry webhook, dead-letter webhook with token-bearing error text), clicked Refresh delivery status, and verified the three entries rendered newest-first with redacted error text",
|
||||
"Resized to 375x812 and re-exercised the delivery log card"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,4 +291,87 @@ describe('NotificationsAPI', () => {
|
|||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes the delivery log payload and drops malformed entries', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
notificationId: 'webhook-1',
|
||||
type: 'webhook',
|
||||
destinationId: 'webhook:wh-ops',
|
||||
outcome: 'dead_letter',
|
||||
alertIds: ['vm-offline-101'],
|
||||
alertCount: 1,
|
||||
attempts: 3,
|
||||
success: false,
|
||||
errorMessage: 'HTTP 401 Unauthorized',
|
||||
failureClass: 'authentication',
|
||||
timestamp: '2026-08-20T12:00:00Z',
|
||||
},
|
||||
// Malformed rows must be dropped, not rendered: unknown outcome,
|
||||
// missing timestamp, and a non-object entry.
|
||||
{
|
||||
notificationId: 'bad-outcome',
|
||||
type: 'email',
|
||||
outcome: 'exploded',
|
||||
timestamp: '2026-08-20T12:00:00Z',
|
||||
},
|
||||
{ notificationId: 'no-timestamp', type: 'email', outcome: 'sent' },
|
||||
'not-an-object',
|
||||
{
|
||||
notificationId: 'email-1',
|
||||
type: 'email',
|
||||
outcome: 'sent',
|
||||
alertIds: ['disk-critical-1'],
|
||||
alertCount: 1,
|
||||
attempts: 1,
|
||||
success: true,
|
||||
// An unrecognized failure class is omitted rather than passed through.
|
||||
failureClass: 'made-up',
|
||||
timestamp: '2026-08-20T11:00:00Z',
|
||||
},
|
||||
],
|
||||
window_days: 7,
|
||||
} as any);
|
||||
|
||||
const log = await NotificationsAPI.getDeliveryLog(25);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/delivery-log?limit=25');
|
||||
expect(log.windowDays).toBe(7);
|
||||
expect(log.entries).toHaveLength(2);
|
||||
expect(log.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
notificationId: 'webhook-1',
|
||||
outcome: 'dead_letter',
|
||||
destinationId: 'webhook:wh-ops',
|
||||
failureClass: 'authentication',
|
||||
errorMessage: 'HTTP 401 Unauthorized',
|
||||
}),
|
||||
);
|
||||
expect(log.entries[1].failureClass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requests the delivery log without a limit query when none is given', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ entries: [], window_days: 0 } as any);
|
||||
|
||||
const log = await NotificationsAPI.getDeliveryLog();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/delivery-log');
|
||||
expect(log.entries).toEqual([]);
|
||||
// A missing or nonsensical window falls back to the seven-day default.
|
||||
expect(log.windowDays).toBe(7);
|
||||
});
|
||||
|
||||
it('passes the deliveryPaused flag through from test-send responses', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
status: 'success',
|
||||
message: 'Test notification sent, but alert delivery is paused',
|
||||
deliveryPaused: true,
|
||||
} as any);
|
||||
|
||||
const result = await NotificationsAPI.testNotification({ type: 'email' });
|
||||
|
||||
expect(result.deliveryPaused).toBe(true);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,6 +129,37 @@ export interface NotificationHealth {
|
|||
queue: NotificationQueueHealth;
|
||||
}
|
||||
|
||||
export type NotificationDeliveryOutcome = 'sent' | 'retry' | 'failed' | 'dead_letter' | 'cancelled';
|
||||
|
||||
export interface NotificationDeliveryLogEntry {
|
||||
notificationId: string;
|
||||
type: string;
|
||||
method?: string;
|
||||
destinationId?: string;
|
||||
outcome: NotificationDeliveryOutcome;
|
||||
alertIds: string[];
|
||||
alertCount: number;
|
||||
attempts: number;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
failureClass?: NotificationFailureClass;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationDeliveryLog {
|
||||
entries: NotificationDeliveryLogEntry[];
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
status?: string;
|
||||
message?: string;
|
||||
// True when the test was delivered but real alert delivery is paused by the
|
||||
// activation gate, so a successful test must not be read as proof that live
|
||||
// alerts are getting through.
|
||||
deliveryPaused?: boolean;
|
||||
}
|
||||
|
||||
function apiRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
|
@ -154,6 +185,49 @@ const notificationFailureClasses: NotificationFailureClass[] = [
|
|||
'unknown',
|
||||
];
|
||||
|
||||
const notificationDeliveryOutcomes: NotificationDeliveryOutcome[] = [
|
||||
'sent',
|
||||
'retry',
|
||||
'failed',
|
||||
'dead_letter',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function normalizeDeliveryLogEntry(value: unknown): NotificationDeliveryLogEntry | undefined {
|
||||
const record = apiRecord(value);
|
||||
const notificationId = strictString(record.notificationId);
|
||||
const outcome = record.outcome;
|
||||
const timestamp = strictString(record.timestamp);
|
||||
if (
|
||||
!notificationId ||
|
||||
!timestamp ||
|
||||
!notificationDeliveryOutcomes.includes(outcome as NotificationDeliveryOutcome)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const failureClass = record.failureClass;
|
||||
const entry: NotificationDeliveryLogEntry = {
|
||||
notificationId,
|
||||
type: strictString(record.type),
|
||||
outcome: outcome as NotificationDeliveryOutcome,
|
||||
alertIds: stringArray(record.alertIds),
|
||||
alertCount: nonNegativeCount(record.alertCount) ?? 0,
|
||||
attempts: nonNegativeCount(record.attempts) ?? 0,
|
||||
success: strictBoolean(record.success),
|
||||
timestamp,
|
||||
};
|
||||
const method = strictString(record.method);
|
||||
if (method) entry.method = method;
|
||||
const destinationId = strictString(record.destinationId);
|
||||
if (destinationId) entry.destinationId = destinationId;
|
||||
const errorMessage = strictString(record.errorMessage);
|
||||
if (errorMessage) entry.errorMessage = errorMessage;
|
||||
if (notificationFailureClasses.includes(failureClass as NotificationFailureClass)) {
|
||||
entry.failureClass = failureClass as NotificationFailureClass;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeFailureClassCounts(value: unknown): NotificationFailureClassCounts | undefined {
|
||||
const record = apiRecord(value);
|
||||
const counts = {} as NotificationFailureClassCounts;
|
||||
|
|
@ -260,6 +334,24 @@ export class NotificationsAPI {
|
|||
};
|
||||
}
|
||||
|
||||
static async getDeliveryLog(limit?: number): Promise<NotificationDeliveryLog> {
|
||||
const query = limit && limit > 0 ? `?limit=${limit}` : '';
|
||||
const payload = await apiFetchJSON<Record<string, unknown>>(
|
||||
`${this.baseUrl}/delivery-log${query}`,
|
||||
);
|
||||
const rawEntries = Array.isArray(payload.entries) ? payload.entries : [];
|
||||
const entries: NotificationDeliveryLogEntry[] = [];
|
||||
for (const rawEntry of rawEntries) {
|
||||
const entry = normalizeDeliveryLogEntry(rawEntry);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
const windowDays = nonNegativeCount(payload.window_days);
|
||||
return {
|
||||
entries,
|
||||
windowDays: windowDays && windowDays > 0 ? windowDays : 7,
|
||||
};
|
||||
}
|
||||
|
||||
static async updateAppriseConfig(config: AppriseConfig): Promise<AppriseConfig> {
|
||||
return apiFetchJSON(`${this.baseUrl}/apprise`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -363,9 +455,7 @@ export class NotificationsAPI {
|
|||
}
|
||||
|
||||
// Testing
|
||||
static async testNotification(
|
||||
request: NotificationTestRequest,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
static async testNotification(request: NotificationTestRequest): Promise<NotificationTestResult> {
|
||||
const body: {
|
||||
method: string;
|
||||
config?: Record<string, unknown> | AppriseConfig;
|
||||
|
|
@ -392,7 +482,7 @@ export class NotificationsAPI {
|
|||
|
||||
static async testWebhook(
|
||||
webhook: Omit<Webhook, 'id'>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
): Promise<{ success?: boolean; message?: string; deliveryPaused?: boolean }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/webhooks/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhook),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { NotificationDeliveryLog, Webhook } from '@/api/notifications';
|
||||
|
||||
import { AlertDeliveryLogCard } from './AlertDeliveryLogCard';
|
||||
|
||||
const webhooks: Webhook[] = [
|
||||
{
|
||||
id: 'wh-ops',
|
||||
name: 'Ops Discord',
|
||||
url: 'https://discord.example.test/hook',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const log: NotificationDeliveryLog = {
|
||||
entries: [
|
||||
{
|
||||
notificationId: 'webhook-1',
|
||||
type: 'webhook',
|
||||
destinationId: 'webhook:wh-ops',
|
||||
outcome: 'dead_letter',
|
||||
alertIds: ['vm-offline-101', 'vm-offline-102'],
|
||||
alertCount: 2,
|
||||
attempts: 3,
|
||||
success: false,
|
||||
errorMessage: 'HTTP 401 Unauthorized',
|
||||
failureClass: 'authentication',
|
||||
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
{
|
||||
notificationId: 'email-1',
|
||||
type: 'email',
|
||||
destinationId: 'destination:abcd',
|
||||
outcome: 'sent',
|
||||
alertIds: ['disk-critical-1'],
|
||||
alertCount: 1,
|
||||
attempts: 1,
|
||||
success: true,
|
||||
timestamp: new Date(Date.now() - 120_000).toISOString(),
|
||||
},
|
||||
],
|
||||
windowDays: 7,
|
||||
};
|
||||
|
||||
describe('AlertDeliveryLogCard', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows each attempt with a plain-language outcome and the resolved destination name', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={log}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Ops Discord')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed, retries exhausted')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delivered')).toBeInTheDocument();
|
||||
expect(screen.getByText('vm-offline-101 +1 more')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Authentication failure/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/HTTP 401 Unauthorized/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says test sends are not listed so their absence is not read as failure', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={log}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText(/Test sends skip the queue/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an honest empty state when no deliveries were attempted', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={{ entries: [], windowDays: 7 }}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText(/No alert deliveries were attempted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports an unreadable log as unavailable instead of empty', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={null}
|
||||
unavailable={true}
|
||||
refreshing={false}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
155
frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx
Normal file
155
frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { For, Show } from 'solid-js';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
|
||||
import type {
|
||||
NotificationDeliveryLog,
|
||||
NotificationDeliveryLogEntry,
|
||||
Webhook,
|
||||
} from '@/api/notifications';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import {
|
||||
getAlertDeliveryLogFailureClassLabel,
|
||||
getAlertDeliveryLogOutcomeLabel,
|
||||
getAlertDestinationsDeliveryLogDescription,
|
||||
getAlertDestinationsDeliveryLogEmpty,
|
||||
getAlertDestinationsDeliveryLogTitle,
|
||||
getAlertDestinationsDeliveryLogUnavailable,
|
||||
getAlertDestinationsDeliveryRefreshLabel,
|
||||
} from '@/utils/alertDestinationsPresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
interface AlertDeliveryLogCardProps {
|
||||
log: NotificationDeliveryLog | null;
|
||||
unavailable: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
webhooks: Webhook[];
|
||||
}
|
||||
|
||||
const WEBHOOK_DESTINATION_PREFIX = 'webhook:';
|
||||
|
||||
const outcomeBadgeClasses: Record<NotificationDeliveryLogEntry['outcome'], string> = {
|
||||
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
retry: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
dead_letter: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
cancelled: 'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300',
|
||||
};
|
||||
|
||||
export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
|
||||
// Destination ids are opaque on purpose (hashes for email/apprise, config
|
||||
// ids for webhooks); only webhook ids can be resolved to a configured name.
|
||||
const destinationLabel = (entry: NotificationDeliveryLogEntry): string => {
|
||||
if (entry.destinationId?.startsWith(WEBHOOK_DESTINATION_PREFIX)) {
|
||||
const webhookId = entry.destinationId.slice(WEBHOOK_DESTINATION_PREFIX.length);
|
||||
const webhook = props.webhooks.find((candidate) => candidate.id === webhookId);
|
||||
if (webhook?.name) return webhook.name;
|
||||
}
|
||||
switch (entry.type) {
|
||||
case 'email':
|
||||
return 'Email';
|
||||
case 'apprise':
|
||||
return 'Apprise';
|
||||
case 'webhook':
|
||||
return 'Webhook';
|
||||
default:
|
||||
return entry.type || 'Destination';
|
||||
}
|
||||
};
|
||||
|
||||
const alertSummary = (entry: NotificationDeliveryLogEntry): string => {
|
||||
if (entry.alertIds.length === 0) {
|
||||
return entry.alertCount === 1 ? '1 alert' : `${entry.alertCount} alerts`;
|
||||
}
|
||||
const [first, ...rest] = entry.alertIds;
|
||||
return rest.length > 0 ? `${first} +${rest.length} more` : first;
|
||||
};
|
||||
|
||||
const entries = () => props.log?.entries ?? [];
|
||||
const windowDays = () => props.log?.windowDays ?? 7;
|
||||
|
||||
return (
|
||||
<Card padding="sm" class="sm:p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{getAlertDestinationsDeliveryLogTitle()}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{getAlertDestinationsDeliveryLogDescription(windowDays())}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-md border border-gray-300 bg-transparent px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/40"
|
||||
disabled={props.refreshing}
|
||||
onClick={props.onRefresh}
|
||||
>
|
||||
<RefreshCwIcon class={`h-4 w-4 ${props.refreshing ? 'animate-spin' : ''}`} />
|
||||
{getAlertDestinationsDeliveryRefreshLabel()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!props.unavailable}
|
||||
fallback={
|
||||
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
|
||||
{getAlertDestinationsDeliveryLogUnavailable()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{getAlertDestinationsDeliveryLogEmpty()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ul class="max-h-80 divide-y divide-gray-200 overflow-y-auto dark:divide-gray-700">
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<li class="flex flex-col gap-1 py-2">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span
|
||||
class={`inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClasses[entry.outcome]}`}
|
||||
>
|
||||
{getAlertDeliveryLogOutcomeLabel(entry.outcome)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{destinationLabel(entry)}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400"
|
||||
title={entry.alertIds.join(', ')}
|
||||
>
|
||||
{alertSummary(entry)}
|
||||
</span>
|
||||
<span class="ml-auto flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatRelativeTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!entry.success && (entry.failureClass || entry.errorMessage)}>
|
||||
<p class="text-xs leading-5 text-red-700 dark:text-red-300">
|
||||
<Show when={entry.failureClass}>
|
||||
{(failureClass) => (
|
||||
<span class="font-medium">
|
||||
{getAlertDeliveryLogFailureClassLabel(failureClass())}
|
||||
{entry.errorMessage ? ': ' : ''}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{entry.errorMessage}
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ vi.mock('@/api/notifications', () => ({
|
|||
NotificationsAPI: {
|
||||
createWebhook: vi.fn(),
|
||||
deleteWebhook: vi.fn(),
|
||||
getDeliveryLog: vi.fn(),
|
||||
getHealth: vi.fn(),
|
||||
getWebhooks: vi.fn(),
|
||||
testNotification: vi.fn(),
|
||||
|
|
@ -25,6 +26,7 @@ vi.mock('@/stores/notifications', () => ({
|
|||
notificationStore: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -72,6 +74,7 @@ describe('useAlertDestinationsTabState', () => {
|
|||
beforeEach(() => {
|
||||
vi.mocked(NotificationsAPI.createWebhook).mockReset();
|
||||
vi.mocked(NotificationsAPI.deleteWebhook).mockReset();
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockReset();
|
||||
vi.mocked(NotificationsAPI.getHealth).mockReset();
|
||||
vi.mocked(NotificationsAPI.getWebhooks).mockReset();
|
||||
vi.mocked(NotificationsAPI.testNotification).mockReset();
|
||||
|
|
@ -79,6 +82,7 @@ describe('useAlertDestinationsTabState', () => {
|
|||
vi.mocked(NotificationsAPI.updateWebhook).mockReset();
|
||||
vi.mocked(notificationStore.error).mockReset();
|
||||
vi.mocked(notificationStore.success).mockReset();
|
||||
vi.mocked(notificationStore.warning).mockReset();
|
||||
vi.mocked(showErrorWithDetail).mockReset();
|
||||
});
|
||||
|
||||
|
|
@ -130,7 +134,22 @@ describe('useAlertDestinationsTabState', () => {
|
|||
failureClassWindowDays: 7,
|
||||
},
|
||||
});
|
||||
vi.mocked(NotificationsAPI.testNotification).mockResolvedValue({ success: true } as never);
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
notificationId: 'email-1',
|
||||
type: 'email',
|
||||
outcome: 'sent',
|
||||
alertIds: ['disk-critical-1'],
|
||||
alertCount: 1,
|
||||
attempts: 1,
|
||||
success: true,
|
||||
timestamp: '2026-08-20T12:00:00Z',
|
||||
},
|
||||
],
|
||||
windowDays: 7,
|
||||
});
|
||||
vi.mocked(NotificationsAPI.testNotification).mockResolvedValue({ status: 'success' } as never);
|
||||
vi.mocked(NotificationsAPI.testWebhook).mockResolvedValue({ success: true } as never);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
|
@ -147,7 +166,10 @@ describe('useAlertDestinationsTabState', () => {
|
|||
|
||||
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(1));
|
||||
expect(result.deliveryHealth()?.queue.status).toBe('healthy');
|
||||
expect(result.deliveryLog()?.entries).toHaveLength(1);
|
||||
expect(result.deliveryLogUnavailable()).toBe(false);
|
||||
expect(result.webhooks()).toEqual([
|
||||
expect.objectContaining({ id: 'hook-1', service: 'generic' }),
|
||||
]);
|
||||
|
|
@ -181,7 +203,49 @@ describe('useAlertDestinationsTabState', () => {
|
|||
expect(onRetryLoad).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(2));
|
||||
expect(notificationStore.success).toHaveBeenCalledTimes(2);
|
||||
expect(notificationStore.warning).not.toHaveBeenCalled();
|
||||
expect(showErrorWithDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns instead of celebrating when a test send reports delivery is paused', async () => {
|
||||
const [emailConfig] = createSignal(buildEmailConfig());
|
||||
const [appriseConfig, setAppriseConfig] = createSignal(buildAppriseConfig());
|
||||
const [configLoadError] = createSignal<string | null>(null);
|
||||
const [isRetrying] = createSignal(false);
|
||||
const [isLoadingDestinations] = createSignal(false);
|
||||
|
||||
vi.mocked(NotificationsAPI.getWebhooks).mockResolvedValue([]);
|
||||
vi.mocked(NotificationsAPI.getHealth).mockRejectedValue(new Error('offline'));
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({ entries: [], windowDays: 7 });
|
||||
// The backend reports the test went out while the activation gate keeps
|
||||
// real alerts suppressed; plain success here is the postmortem trap.
|
||||
vi.mocked(NotificationsAPI.testNotification).mockResolvedValue({
|
||||
status: 'success',
|
||||
deliveryPaused: true,
|
||||
} as never);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAlertDestinationsTabState({
|
||||
appriseConfig,
|
||||
configLoadError,
|
||||
emailConfig,
|
||||
isLoadingDestinations,
|
||||
isRetrying,
|
||||
onRetryLoad: vi.fn(),
|
||||
setAppriseConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
await result.testEmailConfig();
|
||||
expect(notificationStore.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('delivery is paused'),
|
||||
);
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
|
||||
await result.testApprise();
|
||||
expect(notificationStore.warning).toHaveBeenCalledTimes(2);
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { createRoot } from 'solid-js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { NotificationsAPI } from '@/api/notifications';
|
||||
|
||||
import { useNotificationDeliveryLog } from '../useNotificationDeliveryLog';
|
||||
|
||||
vi.mock('@/api/notifications', () => ({
|
||||
NotificationsAPI: { getDeliveryLog: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('useNotificationDeliveryLog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockReset();
|
||||
});
|
||||
|
||||
it('exposes the loaded log and clears the unavailable flag', () =>
|
||||
createRoot(async (dispose) => {
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
notificationId: 'email-1',
|
||||
type: 'email',
|
||||
outcome: 'sent',
|
||||
alertIds: ['disk-critical-1'],
|
||||
alertCount: 1,
|
||||
attempts: 1,
|
||||
success: true,
|
||||
timestamp: '2026-08-20T12:00:00Z',
|
||||
},
|
||||
],
|
||||
windowDays: 7,
|
||||
});
|
||||
const state = useNotificationDeliveryLog();
|
||||
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.deliveryLog()?.entries).toHaveLength(1);
|
||||
expect(state.deliveryLogUnavailable()).toBe(false);
|
||||
dispose();
|
||||
}));
|
||||
|
||||
it('reports an unreadable log as unavailable, never as empty', () =>
|
||||
createRoot(async (dispose) => {
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockRejectedValue(new Error('network down'));
|
||||
const state = useNotificationDeliveryLog();
|
||||
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.deliveryLogUnavailable()).toBe(true);
|
||||
expect(state.deliveryLog()).toBeNull();
|
||||
dispose();
|
||||
}));
|
||||
|
||||
it('recovers the unavailable flag once a later load succeeds', () =>
|
||||
createRoot(async (dispose) => {
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockRejectedValueOnce(new Error('network down'));
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({
|
||||
entries: [],
|
||||
windowDays: 7,
|
||||
});
|
||||
const state = useNotificationDeliveryLog();
|
||||
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.deliveryLogUnavailable()).toBe(true);
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.deliveryLogUnavailable()).toBe(false);
|
||||
expect(state.deliveryLog()?.entries).toHaveLength(0);
|
||||
dispose();
|
||||
}));
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { logger } from '@/utils/logger';
|
|||
import type { AlertDestinationsDeliveryPausedReason } from '@/utils/alertDestinationsPresentation';
|
||||
import { AlertAppriseDestinationsSection } from '../AlertAppriseDestinationsSection';
|
||||
import { AlertDeliveryHealthCard } from '../AlertDeliveryHealthCard';
|
||||
import { AlertDeliveryLogCard } from '../AlertDeliveryLogCard';
|
||||
import { AlertDeliveryPausedCard } from '../AlertDeliveryPausedCard';
|
||||
import { AlertDestinationsLoadErrorCard } from '../AlertDestinationsLoadErrorCard';
|
||||
import { AlertDestinationsLoadingState } from '../AlertDestinationsLoadingState';
|
||||
|
|
@ -120,6 +121,14 @@ export function DestinationsTab(props: DestinationsTabProps) {
|
|||
showUpgradePrompts={!presentationPolicyHidesUpgradePrompts()}
|
||||
upgradeDestination={getUpgradeActionDestination('relay')}
|
||||
/>
|
||||
|
||||
<AlertDeliveryLogCard
|
||||
log={state.deliveryLog()}
|
||||
unavailable={state.deliveryLogUnavailable()}
|
||||
refreshing={state.refreshingDeliveryLog()}
|
||||
onRefresh={() => void state.loadDeliveryLog()}
|
||||
webhooks={state.webhooks()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import {
|
|||
getAlertDestinationsAppriseValidationError,
|
||||
getAlertDestinationsEmailTestFailure,
|
||||
getAlertDestinationsEmailTestSuccess,
|
||||
getAlertDestinationsTestPausedWarning,
|
||||
} from '@/utils/alertDestinationsPresentation';
|
||||
|
||||
import { parseAppriseTargets } from './helpers';
|
||||
import type { UIAppriseConfig, UIEmailConfig } from './types';
|
||||
import { useNotificationDeliveryHealth } from './useNotificationDeliveryHealth';
|
||||
import { useNotificationDeliveryLog } from './useNotificationDeliveryLog';
|
||||
import { useAlertWebhookDestinationsState } from './useAlertWebhookDestinationsState';
|
||||
|
||||
export interface AlertDestinationsTabStateProps {
|
||||
|
|
@ -37,6 +39,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
|||
deliveryNeedsAttention,
|
||||
loadDeliveryHealth,
|
||||
} = useNotificationDeliveryHealth();
|
||||
const { deliveryLog, deliveryLogUnavailable, refreshingDeliveryLog, loadDeliveryLog } =
|
||||
useNotificationDeliveryLog();
|
||||
const webhookState = useAlertWebhookDestinationsState();
|
||||
|
||||
const isLoading = createMemo(
|
||||
|
|
@ -70,11 +74,15 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
|||
const testEmailConfig = async () => {
|
||||
setTestingEmail(true);
|
||||
try {
|
||||
await NotificationsAPI.testNotification({
|
||||
const result = await NotificationsAPI.testNotification({
|
||||
type: 'email',
|
||||
config: { ...props.emailConfig() } as Record<string, unknown>,
|
||||
});
|
||||
notificationStore.success(getAlertDestinationsEmailTestSuccess());
|
||||
if (result.deliveryPaused) {
|
||||
notificationStore.warning(getAlertDestinationsTestPausedWarning());
|
||||
} else {
|
||||
notificationStore.success(getAlertDestinationsEmailTestSuccess());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(getAlertDestinationsEmailTestFailure(), error);
|
||||
const message =
|
||||
|
|
@ -103,11 +111,15 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
|||
throw new Error(getAlertDestinationsAppriseValidationError('missingServerUrl'));
|
||||
}
|
||||
|
||||
await NotificationsAPI.testNotification({
|
||||
const result = await NotificationsAPI.testNotification({
|
||||
type: 'apprise',
|
||||
config,
|
||||
});
|
||||
notificationStore.success(getAlertDestinationsAppriseTestSuccess());
|
||||
if (result.deliveryPaused) {
|
||||
notificationStore.warning(getAlertDestinationsTestPausedWarning());
|
||||
} else {
|
||||
notificationStore.success(getAlertDestinationsAppriseTestSuccess());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(getAlertDestinationsAppriseTestFailure(), error);
|
||||
const message =
|
||||
|
|
@ -123,22 +135,28 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
|||
props.onRetryLoad();
|
||||
void webhookState.loadWebhooks();
|
||||
void loadDeliveryHealth();
|
||||
void loadDeliveryLog();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void loadDeliveryHealth();
|
||||
void loadDeliveryLog();
|
||||
});
|
||||
|
||||
return {
|
||||
appriseState,
|
||||
deliveryHealth,
|
||||
deliveryHealthUnavailable,
|
||||
deliveryLog,
|
||||
deliveryLogUnavailable,
|
||||
deliveryNeedsAttention,
|
||||
handleRetry,
|
||||
hasLoadError,
|
||||
isLoading,
|
||||
loadDeliveryHealth,
|
||||
loadDeliveryLog,
|
||||
refreshingDeliveryHealth,
|
||||
refreshingDeliveryLog,
|
||||
testApprise,
|
||||
testEmailConfig,
|
||||
testingApprise,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { NotificationsAPI, type Webhook } from '@/api/notifications';
|
|||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { showErrorWithDetail } from '@/utils/toast';
|
||||
import { getAlertDestinationsWebhookLoadError } from '@/utils/alertDestinationsPresentation';
|
||||
import {
|
||||
getAlertDestinationsTestPausedWarning,
|
||||
getAlertDestinationsWebhookLoadError,
|
||||
} from '@/utils/alertDestinationsPresentation';
|
||||
import {
|
||||
getAlertWebhookMutationFailure,
|
||||
getAlertWebhookMutationSuccess,
|
||||
|
|
@ -44,12 +47,14 @@ export function useAlertWebhookDestinationsState() {
|
|||
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
|
||||
setTestingWebhook(webhookId);
|
||||
try {
|
||||
if (webhookData) {
|
||||
await NotificationsAPI.testWebhook(webhookData);
|
||||
const result = webhookData
|
||||
? await NotificationsAPI.testWebhook(webhookData)
|
||||
: await NotificationsAPI.testNotification({ type: 'webhook', webhookId });
|
||||
if (result.deliveryPaused) {
|
||||
notificationStore.warning(getAlertDestinationsTestPausedWarning());
|
||||
} else {
|
||||
await NotificationsAPI.testNotification({ type: 'webhook', webhookId });
|
||||
notificationStore.success(getAlertWebhookTestSuccess());
|
||||
}
|
||||
notificationStore.success(getAlertWebhookTestSuccess());
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : getAlertWebhookTestFailure();
|
||||
const detail = (error as Error & { detail?: string })?.detail;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { NotificationsAPI, type NotificationDeliveryLog } from '@/api/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
// The delivery log is the positive half of delivery evidence: health warns
|
||||
// when something is wrong, the log shows what actually fired and where it
|
||||
// went. A log that cannot be read is reported as unavailable, never as empty,
|
||||
// because "no attempts" and "cannot tell" mean opposite things to someone
|
||||
// deciding whether to trust their alerting.
|
||||
export function useNotificationDeliveryLog() {
|
||||
const [deliveryLog, setDeliveryLog] = createSignal<NotificationDeliveryLog | null>(null);
|
||||
const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false);
|
||||
const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false);
|
||||
|
||||
const loadDeliveryLog = async () => {
|
||||
setRefreshingDeliveryLog(true);
|
||||
try {
|
||||
const log = await NotificationsAPI.getDeliveryLog();
|
||||
setDeliveryLog(log);
|
||||
setDeliveryLogUnavailable(false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load notification delivery log', error);
|
||||
setDeliveryLog(null);
|
||||
setDeliveryLogUnavailable(true);
|
||||
} finally {
|
||||
setRefreshingDeliveryLog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deliveryLog,
|
||||
deliveryLogUnavailable,
|
||||
refreshingDeliveryLog,
|
||||
loadDeliveryLog,
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ import {
|
|||
ALERT_DESTINATIONS_RETRYING_LABEL,
|
||||
ALERT_DESTINATIONS_RETRY_LABEL,
|
||||
ALERT_DESTINATIONS_WEBHOOK_LOAD_ERROR,
|
||||
getAlertDeliveryLogFailureClassLabel,
|
||||
getAlertDeliveryLogOutcomeLabel,
|
||||
getAlertDestinationsDeliveryLogDescription,
|
||||
getAlertDestinationsDeliveryLogEmpty,
|
||||
getAlertDestinationsDeliveryLogTitle,
|
||||
getAlertDestinationsDeliveryLogUnavailable,
|
||||
getAlertDestinationsTestPausedWarning,
|
||||
getAlertDestinationsAppriseTargetsHelp,
|
||||
getAlertDestinationsAppriseTestLabel,
|
||||
getAlertDestinationsAppriseTestError,
|
||||
|
|
@ -166,6 +173,42 @@ describe('alertDestinationsPresentation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('alert destinations delivery log copy', () => {
|
||||
it('labels every delivery outcome in plain language, not queue jargon', () => {
|
||||
expect(getAlertDeliveryLogOutcomeLabel('sent')).toBe('Delivered');
|
||||
expect(getAlertDeliveryLogOutcomeLabel('retry')).toBe('Retrying');
|
||||
expect(getAlertDeliveryLogOutcomeLabel('failed')).toBe('Failed');
|
||||
// "Dead letter" is queue vocabulary; a user needs to know retries stopped.
|
||||
expect(getAlertDeliveryLogOutcomeLabel('dead_letter')).toBe('Failed, retries exhausted');
|
||||
expect(getAlertDeliveryLogOutcomeLabel('cancelled')).toBe('Cancelled');
|
||||
});
|
||||
|
||||
it('labels failure classes and falls back to unclassified for unknown values', () => {
|
||||
expect(getAlertDeliveryLogFailureClassLabel('authentication')).toBe('Authentication failure');
|
||||
expect(getAlertDeliveryLogFailureClassLabel('rate_limited')).toBe('Rate limited');
|
||||
expect(getAlertDeliveryLogFailureClassLabel('made-up-class')).toBe('Unclassified failure');
|
||||
});
|
||||
|
||||
it('names the retention window and the test-send caveat so absence is not read as failure', () => {
|
||||
expect(getAlertDestinationsDeliveryLogTitle()).toBe('Recent delivery activity');
|
||||
const description = getAlertDestinationsDeliveryLogDescription(7);
|
||||
expect(description).toContain('last 7 days');
|
||||
expect(description).toContain('Test sends skip the queue');
|
||||
expect(getAlertDestinationsDeliveryLogEmpty()).toContain('No alert deliveries were attempted');
|
||||
// An unreadable log must never present itself as an empty one.
|
||||
expect(getAlertDestinationsDeliveryLogUnavailable()).toContain(
|
||||
'could not read the delivery log',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that a passing test does not mean live alerts flow while delivery is paused', () => {
|
||||
const warning = getAlertDestinationsTestPausedWarning();
|
||||
expect(warning).toContain('Test sent');
|
||||
expect(warning).toContain('delivery is paused');
|
||||
expect(warning).toContain('real alerts are not being sent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert destinations delivery paused copy', () => {
|
||||
it('names the consequence and the test-send caveat for every paused reason', () => {
|
||||
expect(getAlertDestinationsDeliveryPausedTitle()).toBe('Notifications are paused');
|
||||
|
|
|
|||
|
|
@ -199,6 +199,74 @@ export function getAlertDestinationsDeliveryRefreshLabel() {
|
|||
return ALERT_DESTINATIONS_DELIVERY_REFRESH_LABEL;
|
||||
}
|
||||
|
||||
// Shown instead of the plain test-success toast when the backend reports the
|
||||
// test went out while real alert delivery is paused. Without this, a
|
||||
// successful test is exactly how installs come to believe delivery works
|
||||
// while every live alert is being suppressed.
|
||||
export const ALERT_DESTINATIONS_TEST_PAUSED_WARNING =
|
||||
'Test sent, but notification delivery is paused: real alerts are not being sent. Turn on delivery at the top of this page.';
|
||||
|
||||
export function getAlertDestinationsTestPausedWarning() {
|
||||
return ALERT_DESTINATIONS_TEST_PAUSED_WARNING;
|
||||
}
|
||||
|
||||
export const ALERT_DESTINATIONS_DELIVERY_LOG_TITLE = 'Recent delivery activity';
|
||||
export const ALERT_DESTINATIONS_DELIVERY_LOG_EMPTY =
|
||||
'No alert deliveries were attempted in this window.';
|
||||
export const ALERT_DESTINATIONS_DELIVERY_LOG_UNAVAILABLE =
|
||||
'Pulse could not read the delivery log, so recent delivery activity cannot be shown.';
|
||||
|
||||
export function getAlertDestinationsDeliveryLogTitle() {
|
||||
return ALERT_DESTINATIONS_DELIVERY_LOG_TITLE;
|
||||
}
|
||||
|
||||
// The test-send caveat matters: test messages skip the queue entirely, so a
|
||||
// user who sends a test and then checks this log would otherwise read its
|
||||
// absence as a delivery failure.
|
||||
export function getAlertDestinationsDeliveryLogDescription(windowDays: number) {
|
||||
return `Delivery attempts for real alerts over the last ${windowDays} days. Test sends skip the queue and are not listed here.`;
|
||||
}
|
||||
|
||||
export function getAlertDestinationsDeliveryLogEmpty() {
|
||||
return ALERT_DESTINATIONS_DELIVERY_LOG_EMPTY;
|
||||
}
|
||||
|
||||
export function getAlertDestinationsDeliveryLogUnavailable() {
|
||||
return ALERT_DESTINATIONS_DELIVERY_LOG_UNAVAILABLE;
|
||||
}
|
||||
|
||||
export type AlertDeliveryLogOutcome = 'sent' | 'retry' | 'failed' | 'dead_letter' | 'cancelled';
|
||||
|
||||
// One plain-language label per outcome. "Dead letter" is queue jargon; what a
|
||||
// user needs to know is that Pulse stopped retrying.
|
||||
export function getAlertDeliveryLogOutcomeLabel(outcome: AlertDeliveryLogOutcome) {
|
||||
switch (outcome) {
|
||||
case 'sent':
|
||||
return 'Delivered';
|
||||
case 'retry':
|
||||
return 'Retrying';
|
||||
case 'dead_letter':
|
||||
return 'Failed, retries exhausted';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlertDeliveryLogFailureClassLabel(failureClass: string) {
|
||||
const labels: Record<string, string> = {
|
||||
authentication: 'Authentication failure',
|
||||
rate_limited: 'Rate limited',
|
||||
connectivity: 'Connectivity failure',
|
||||
tls: 'TLS failure',
|
||||
configuration: 'Configuration problem',
|
||||
rejected: 'Rejected by destination',
|
||||
unknown: 'Unclassified failure',
|
||||
};
|
||||
return labels[failureClass] ?? labels.unknown;
|
||||
}
|
||||
|
||||
const ALERT_DESTINATIONS_DELIVERY_PAUSED_TITLE = 'Notifications are paused';
|
||||
const ALERT_DESTINATIONS_DELIVERY_PAUSED_ACTION = 'Turn on delivery';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue