fix notification terminal failure recovery

This commit is contained in:
rcourtman
2026-08-23 09:05:54 +01:00
parent 434e1448ff
commit bce5564025
28 changed files with 651 additions and 26 deletions
+6
View File
@@ -600,6 +600,12 @@ Common reporting error codes:
- `GET /api/notifications/dlq` (admin)
- `POST /api/notifications/dlq/retry` (admin)
- `POST /api/notifications/dlq/delete` (admin)
- `POST /api/notifications/terminal-failures/retry` (admin, `settings:write`)
- Returns every retained `failed` or `dlq` delivery to `pending` with a fresh
retry budget. Existing per-attempt delivery history is preserved.
- `POST /api/notifications/terminal-failures/dismiss` (admin, `settings:write`)
- Marks every retained terminal delivery `cancelled`, clearing the active
queue-health warning without deleting delivery history.
---
+7
View File
@@ -151,6 +151,10 @@ first step before platform-local cleanup.
- Open **Alerts → Notifications** first. Pulse shows a delivery warning when
failed or dead-lettered notifications remain in the persistent queue; a
missing queue-health read is shown as unavailable rather than healthy.
- After correcting the destination, use **Retry retained deliveries**. Use
**Dismiss retained failures** only when those deliveries should not be sent.
Both actions preserve delivery history; do not delete `notification_queue.db`
to clear the warning.
- Check SMTP settings in **Alerts → Notifications**.
- Check logs: `docker logs pulse | grep email`.
- Ensure your SMTP provider allows the connection (e.g., Gmail App Passwords).
@@ -159,6 +163,9 @@ first step before platform-local cleanup.
- Check the delivery warning in **Alerts → Notifications** and use **Send test**
after correcting the destination. Recoverable retries do not trigger the
warning; retained terminal failures do.
- If the test succeeds, use **Retry retained deliveries** to give the retained
items a fresh retry budget. Dismiss them only when delivery is no longer
wanted; neither action deletes the audit trail.
- Verify the URL is reachable from the Pulse server.
- If targeting private IPs, allow them in **Settings → System → Network → Webhook Security**.
- Check Pulse logs for HTTP status codes and response bodies.
@@ -192,6 +192,10 @@ Docker/Podman module attached to that host identity. The removal must delete
the associated runtime report surfaces and clear their active alerts instead
of leaving container alerts or inventory detached from a removed agent.
Notification terminal-failure retry and dismissal are delivery-queue operator
actions only. They do not renew agent enrollment, heartbeat, command-channel
readiness, update observation, or any agent-lifecycle lease.
Proxmox node and backup-server setup may request an explicitly insecure
Unified Agent installer command. The choice must remain off by default, be
visible at the point where the command is generated, and affect both the
@@ -258,6 +258,11 @@ frontend-primitives: row, mobile-card, global-default, and bulk-selection
icon-only actions must compose `ActionIconButton` for shared size, tone, focus,
title, and accessible-name behavior instead of rendering local `<button>` plus
inline SVG shells in alert-owned runtime components.
The notification-delivery health card is an alerts-owned presentation over
notification-owned queue truth. When retained terminal failures exist, the
Destinations surface exposes explicit retry and dismiss actions with
consequence confirmations, refreshes health and delivery history after either
action, and never instructs the operator to delete queue storage.
Alert runtime state has one explicit ownership boundary: `AlertConfig.enabled`
controls detector evaluation and in-product alert visibility, while
`AlertConfig.activationState` controls external notification delivery only.
@@ -72,6 +72,12 @@ The same live update applies all four `schedule.grouping` fields atomically.
When grouping is disabled, pending and subsequent alerts are delivered
individually; the API boundary must not reduce that setting to the window or
grouping-key fields while silently ignoring `enabled`.
`POST /api/notifications/terminal-failures/retry` and
`POST /api/notifications/terminal-failures/dismiss` are admin,
`settings:write` operator actions. Their strict response is
`{"success": true, "affected": <non-negative integer>}`; frontend clients
must normalize malformed counts to zero and must not consume the raw DLQ
payload, which can contain notification configuration and content.
`DELETE /api/ai/patrol/suppressions/finding_{findingID}` is the canonical
reopen path for a dismissed Patrol finding. It removes the finding-backed
suppression row, preserves the operator note, clears dismissal state in both
@@ -22,6 +22,10 @@
Own reusable frontend primitives and canonical page-shell patterns so feature
work extends shared components instead of creating new local variants.
Feature-owned warning cards, including notification delivery health, compose
the shared `Button` variants for retry, dismiss, refresh, loading, and disabled
states. Feature code owns the action copy and confirmation consequences, but
must not recreate local button chrome for those controls.
The alert schedule's initial-delivery selector composes `SettingsPanel` and
`FormSelect`, uses the shared alert-configuration presentation vocabulary, and
exposes the same email, webhook, Apprise, and all-destination labels used by
@@ -331,6 +331,15 @@ counts cannot be read. Pending retries do not degrade health. The response
must expose fixed reason codes and retention metadata rather than raw queue
errors or notification content.
Operator recovery is owned by the queue, not by database-file deletion. The
settings-write retry action returns all retained `failed` and `dlq` rows to
`pending`, resets their queue-attempt counters to a fresh retry budget, keeps
their operational links, and wakes the processor; it never rewrites existing
per-attempt audit rows. The settings-write dismiss action transitions those
same rows to `cancelled`, preserves both queue and audit history, and clears
the active health warning. Both actions are transactional across the selected
terminal set and report the number of rows actually transitioned.
### User-facing delivery log and honest test sends
The queue owner exposes its retained per-attempt audit rows to the local
@@ -201,6 +201,9 @@ active-alert read model only while detector evaluation is enabled. They must
not hide storage or recovery alerts when external notification delivery is
pending, paused, snoozed, unavailable, or destination-disabled; those delivery
states do not change the underlying storage evidence or active lifecycle.
Likewise, retrying or dismissing retained notification failures changes only
delivery-queue state and its warning. It does not alter storage health,
protection posture, recovery-point evidence, or restore readiness.
## Extension Points
+21 -15
View File
@@ -1,17 +1,24 @@
{
"version": 1,
"base_sha": "af6d4825152580779c21a48bc13a702c15a4a982",
"verified_at": "2026-08-21T05:48:24Z",
"base_sha": "434e1448ff70464bccec3e6ec46257d10da2162f",
"verified_at": "2026-08-23T08:04:16Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
"frontend-modern/src/api/notifications.ts",
"frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
"frontend-modern/src/utils/alertDestinationsPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "afcb6b556a0bb21e19b492744a62f97513206c27f3e8d643a2c8862e2e43fe13"
"frontend-modern/src/api/notifications.ts": "df77323e526d5c528bd4fb7fcfdccb70b5823c5b0dd41525718067ffd441b0b5",
"frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx": "0e9c53191da45f5c81f336c1034bc0e0651d88f92fc89b268d9a997dcd7d1b20",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "23cc5d7800028b35019359af2b46937b5fef308bdf82116240fa92c21e16177c",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "6993829996d1acd877f310212f045f33ee9f92b56cb1fee62e293cf4c9b8f14e",
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "52bf726d4b5a081db84c1be05bce466231749c73c0f2b43acce4cec11b7ff80b"
},
"routes": [
"/settings/infrastructure",
"/settings/infrastructure (Agent Doctor view via Open Agent Doctor)"
"/alerts/notifications"
],
"viewports": [
{
@@ -24,16 +31,15 @@
}
],
"states": [
"Agent Doctor fleet table with 1 Critical and 12 Healthy targets (mock stack against live dev backend)",
"Expanded Critical row prod-euw1-k8s-03 showing stale-report diagnostic, no credential repair offered",
"Expanded Healthy row apollo-114 showing no fleet-health issues",
"Doctor table re-rendered at mobile width after reload",
"No repair_authentication target exists in the mock fleet, so the replaceCredential command branch was verified by unit tests (InfrastructureOperationsModel.test.tsx source pin plus agentInstallCommand builder tests), not by a rendered command"
"Healthy queue with no retained terminal deliveries: recovery actions correctly absent and delivery activity empty",
"Notifications-paused warning remains distinct from queue health and explains that test sends bypass the activation gate",
"Desktop and narrow layouts render the delivery activity card without horizontal overflow or console errors",
"Retained-failure warning, retry/dismiss controls, confirmation consequences, loading states, API refresh, and success/error paths verified by focused component, state, API, and queue tests because the live development queue had no terminal failures"
],
"interactions": [
"Clicked Open Agent Doctor from Infrastructure settings",
"Expanded the Critical target row prod-euw1-k8s-03",
"Expanded the Healthy target row apollo-114",
"Reloaded the doctor view at 375x812 and confirmed clean render with zero console errors"
"Navigated from Alerts overview to Notifications",
"Refreshed healthy delivery status on the desktop layout",
"Reloaded /alerts/notifications at 1280x800 and 375x812",
"Confirmed Recent delivery activity and Refresh delivery status remain visible and usable at narrow width"
]
}
+6
View File
@@ -600,6 +600,12 @@ Common reporting error codes:
- `GET /api/notifications/dlq` (admin)
- `POST /api/notifications/dlq/retry` (admin)
- `POST /api/notifications/dlq/delete` (admin)
- `POST /api/notifications/terminal-failures/retry` (admin, `settings:write`)
- Returns every retained `failed` or `dlq` delivery to `pending` with a fresh
retry budget. Existing per-attempt delivery history is preserved.
- `POST /api/notifications/terminal-failures/dismiss` (admin, `settings:write`)
- Marks every retained terminal delivery `cancelled`, clearing the active
queue-health warning without deleting delivery history.
---
@@ -151,6 +151,10 @@ first step before platform-local cleanup.
- Open **Alerts → Notifications** first. Pulse shows a delivery warning when
failed or dead-lettered notifications remain in the persistent queue; a
missing queue-health read is shown as unavailable rather than healthy.
- After correcting the destination, use **Retry retained deliveries**. Use
**Dismiss retained failures** only when those deliveries should not be sent.
Both actions preserve delivery history; do not delete `notification_queue.db`
to clear the warning.
- Check SMTP settings in **Alerts → Notifications**.
- Check logs: `docker logs pulse | grep email`.
- Ensure your SMTP provider allows the connection (e.g., Gmail App Passwords).
@@ -159,6 +163,9 @@ first step before platform-local cleanup.
- Check the delivery warning in **Alerts → Notifications** and use **Send test**
after correcting the destination. Recoverable retries do not trigger the
warning; retained terminal failures do.
- If the test succeeds, use **Retry retained deliveries** to give the retained
items a fresh retry budget. Dismiss them only when delivery is no longer
wanted; neither action deletes the audit trail.
- Verify the URL is reachable from the Pulse server.
- If targeting private IPs, allow them in **Settings → System → Network → Webhook Security**.
- Check Pulse logs for HTTP status codes and response bodies.
@@ -373,6 +373,28 @@ describe('NotificationsAPI', () => {
expect(log).toEqual({ entries: [], windowDays: 7 });
});
it('retries retained terminal deliveries through the operator recovery endpoint', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ affected: 3, success: true } as any);
const result = await NotificationsAPI.retryTerminalFailures();
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/terminal-failures/retry', {
method: 'POST',
});
expect(result).toEqual({ affected: 3, success: true });
});
it('dismisses retained terminal failures and fails closed on malformed counts', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ affected: -1, success: 'yes' } as any);
const result = await NotificationsAPI.dismissTerminalFailures();
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/terminal-failures/dismiss', {
method: 'POST',
});
expect(result).toEqual({ affected: 0, success: false });
});
it('passes the deliveryPaused flag through from test-send responses', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
status: 'success',
+27
View File
@@ -129,6 +129,11 @@ export interface NotificationHealth {
queue: NotificationQueueHealth;
}
export interface NotificationTerminalFailureActionResult {
success: boolean;
affected: number;
}
export type NotificationDeliveryOutcome = 'sent' | 'retry' | 'failed' | 'dead_letter' | 'cancelled';
export interface NotificationDeliveryLogEntry {
@@ -352,6 +357,28 @@ export class NotificationsAPI {
};
}
static async retryTerminalFailures(): Promise<NotificationTerminalFailureActionResult> {
const payload = await apiFetchJSON<Record<string, unknown>>(
`${this.baseUrl}/terminal-failures/retry`,
{ method: 'POST' },
);
return {
success: strictBoolean(payload.success),
affected: nonNegativeCount(payload.affected) ?? 0,
};
}
static async dismissTerminalFailures(): Promise<NotificationTerminalFailureActionResult> {
const payload = await apiFetchJSON<Record<string, unknown>>(
`${this.baseUrl}/terminal-failures/dismiss`,
{ method: 'POST' },
);
return {
success: strictBoolean(payload.success),
affected: nonNegativeCount(payload.affected) ?? 0,
};
}
static async updateAppriseConfig(config: AppriseConfig): Promise<AppriseConfig> {
return apiFetchJSON(`${this.baseUrl}/apprise`, {
method: 'PUT',
@@ -38,12 +38,16 @@ describe('AlertDeliveryHealthCard', () => {
it('tells operators that retained terminal deliveries were not delivered', () => {
const onRefresh = vi.fn();
const onRetryFailures = vi.fn();
const onDismissFailures = vi.fn();
render(() => (
<AlertDeliveryHealthCard
health={degradedHealth}
unavailable={false}
refreshing={false}
onRefresh={onRefresh}
onRetryFailures={onRetryFailures}
onDismissFailures={onDismissFailures}
/>
));
@@ -62,6 +66,12 @@ describe('AlertDeliveryHealthCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Refresh delivery status' }));
expect(onRefresh).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Retry retained deliveries' }));
expect(onRetryFailures).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Dismiss retained failures' }));
expect(onDismissFailures).toHaveBeenCalledTimes(1);
});
it('fails closed when queue health cannot be verified', () => {
@@ -2,11 +2,14 @@ import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import type { NotificationQueueHealth } from '@/api/notifications';
import { Button } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import {
getAlertDestinationsDeliveryDismissLabel,
getAlertDestinationsDeliveryHealthDescription,
getAlertDestinationsDeliveryHealthTitle,
getAlertDestinationsDeliveryRefreshLabel,
getAlertDestinationsDeliveryRetryLabel,
} from '@/utils/alertDestinationsPresentation';
interface AlertDeliveryHealthCardProps {
@@ -14,6 +17,10 @@ interface AlertDeliveryHealthCardProps {
unavailable: boolean;
refreshing: boolean;
onRefresh: () => void;
retryingFailures?: boolean;
dismissingFailures?: boolean;
onRetryFailures?: () => void;
onDismissFailures?: () => void;
}
export function AlertDeliveryHealthCard(props: AlertDeliveryHealthCardProps) {
@@ -46,15 +53,39 @@ export function AlertDeliveryHealthCard(props: AlertDeliveryHealthCardProps) {
</p>
</div>
</div>
<button
type="button"
class="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-md border border-red-300 bg-transparent px-3 py-1.5 text-sm font-medium text-red-800 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-700 dark:text-red-200 dark:hover:bg-red-900/30"
disabled={props.refreshing}
onClick={props.onRefresh}
>
<RefreshCwIcon class={`h-4 w-4 ${props.refreshing ? 'animate-spin' : ''}`} />
{getAlertDestinationsDeliveryRefreshLabel()}
</button>
<div class="flex flex-shrink-0 flex-wrap items-center gap-2">
{props.onRetryFailures ? (
<Button
variant="secondary"
size="sm"
isLoading={props.retryingFailures}
disabled={props.dismissingFailures}
onClick={props.onRetryFailures}
>
{getAlertDestinationsDeliveryRetryLabel()}
</Button>
) : null}
{props.onDismissFailures ? (
<Button
variant="danger"
size="sm"
isLoading={props.dismissingFailures}
disabled={props.retryingFailures}
onClick={props.onDismissFailures}
>
{getAlertDestinationsDeliveryDismissLabel()}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={props.refreshing || props.retryingFailures || props.dismissingFailures}
onClick={props.onRefresh}
>
<RefreshCwIcon class={`mr-2 h-4 w-4 ${props.refreshing ? 'animate-spin' : ''}`} />
{getAlertDestinationsDeliveryRefreshLabel()}
</Button>
</div>
</div>
</Card>
);
@@ -16,6 +16,8 @@ vi.mock('@/api/notifications', () => ({
getDeliveryLog: vi.fn(),
getHealth: vi.fn(),
getWebhooks: vi.fn(),
dismissTerminalFailures: vi.fn(),
retryTerminalFailures: vi.fn(),
testNotification: vi.fn(),
testWebhook: vi.fn(),
updateWebhook: vi.fn(),
@@ -77,6 +79,8 @@ describe('useAlertDestinationsTabState', () => {
vi.mocked(NotificationsAPI.getDeliveryLog).mockReset();
vi.mocked(NotificationsAPI.getHealth).mockReset();
vi.mocked(NotificationsAPI.getWebhooks).mockReset();
vi.mocked(NotificationsAPI.dismissTerminalFailures).mockReset();
vi.mocked(NotificationsAPI.retryTerminalFailures).mockReset();
vi.mocked(NotificationsAPI.testNotification).mockReset();
vi.mocked(NotificationsAPI.testWebhook).mockReset();
vi.mocked(NotificationsAPI.updateWebhook).mockReset();
@@ -248,4 +252,82 @@ describe('useAlertDestinationsTabState', () => {
expect(notificationStore.warning).toHaveBeenCalledTimes(2);
expect(notificationStore.success).not.toHaveBeenCalled();
});
it('confirms and resolves retained terminal deliveries without deleting delivery history', async () => {
const [emailConfig] = createSignal(buildEmailConfig());
const [appriseConfig, setAppriseConfig] = createSignal(buildAppriseConfig());
const [configLoadError] = createSignal<string | null>(null);
const [isRetrying] = createSignal(false);
const [isLoadingDestinations] = createSignal(false);
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
vi.mocked(NotificationsAPI.getWebhooks).mockResolvedValue([]);
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
overallHealthy: false,
queue: {
attentionRequired: 3,
completedRetentionDays: 7,
countsAreRetentionBounded: true,
deadLetter: 2,
deadLetterRetentionDays: 30,
failed: 1,
failureClasses7d: {
authentication: 3,
configuration: 0,
connectivity: 0,
rate_limited: 0,
rejected: 0,
tls: 0,
unknown: 0,
},
failureClassesAvailable: true,
failureClassWindowDays: 7,
healthy: false,
pending: 0,
reasonCodes: ['retained_failed_deliveries', 'retained_dead_letter_deliveries'],
retryAttemptsAffectHealth: false,
sending: 0,
sent: 0,
status: 'degraded',
terminalFailuresAffectHealth: true,
},
});
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({ entries: [], windowDays: 7 });
vi.mocked(NotificationsAPI.retryTerminalFailures).mockResolvedValue({
affected: 3,
success: true,
});
vi.mocked(NotificationsAPI.dismissTerminalFailures).mockResolvedValue({
affected: 3,
success: true,
});
const { result } = renderHook(() =>
useAlertDestinationsTabState({
appriseConfig,
configLoadError,
emailConfig,
isLoadingDestinations,
isRetrying,
onRetryLoad: vi.fn(),
setAppriseConfig,
}),
);
await waitFor(() => expect(result.deliveryNeedsAttention()).toBe(true));
await result.retryTerminalFailures();
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Retry 3 retained deliveries'));
expect(NotificationsAPI.retryTerminalFailures).toHaveBeenCalledTimes(1);
expect(notificationStore.success).toHaveBeenCalledWith(
'3 retained deliveries queued for retry.',
);
await result.dismissTerminalFailures();
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Dismiss 3 retained failures'));
expect(NotificationsAPI.dismissTerminalFailures).toHaveBeenCalledTimes(1);
expect(notificationStore.success).toHaveBeenCalledWith('3 retained failures dismissed.');
expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(3);
confirmSpy.mockRestore();
});
});
@@ -80,6 +80,10 @@ export function DestinationsTab(props: DestinationsTabProps) {
unavailable={state.deliveryHealthUnavailable()}
refreshing={state.refreshingDeliveryHealth()}
onRefresh={() => void state.loadDeliveryHealth()}
retryingFailures={state.retryingTerminalFailures()}
dismissingFailures={state.dismissingTerminalFailures()}
onRetryFailures={() => void state.retryTerminalFailures()}
onDismissFailures={() => void state.dismissTerminalFailures()}
/>
</Show>
@@ -8,6 +8,8 @@ import {
getAlertDestinationsAppriseTestFailure,
getAlertDestinationsAppriseTestSuccess,
getAlertDestinationsAppriseValidationError,
getAlertDestinationsDeliveryDismissConfirmation,
getAlertDestinationsDeliveryRetryConfirmation,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsTestPausedWarning,
@@ -32,6 +34,8 @@ export interface AlertDestinationsTabStateProps {
export function useAlertDestinationsTabState(props: AlertDestinationsTabStateProps) {
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const [retryingTerminalFailures, setRetryingTerminalFailures] = createSignal(false);
const [dismissingTerminalFailures, setDismissingTerminalFailures] = createSignal(false);
const {
deliveryHealth,
deliveryHealthUnavailable,
@@ -138,6 +142,46 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
void loadDeliveryLog();
};
const retryTerminalFailures = async () => {
const count = deliveryHealth()?.queue.attentionRequired ?? 0;
if (count <= 0 || !confirm(getAlertDestinationsDeliveryRetryConfirmation(count))) {
return;
}
setRetryingTerminalFailures(true);
try {
const result = await NotificationsAPI.retryTerminalFailures();
notificationStore.success(
`${result.affected} retained ${result.affected === 1 ? 'delivery' : 'deliveries'} queued for retry.`,
);
await Promise.all([loadDeliveryHealth(), loadDeliveryLog()]);
} catch (error) {
logger.error('Failed to retry retained notification deliveries', error);
notificationStore.error('Unable to retry retained notification deliveries.');
} finally {
setRetryingTerminalFailures(false);
}
};
const dismissTerminalFailures = async () => {
const count = deliveryHealth()?.queue.attentionRequired ?? 0;
if (count <= 0 || !confirm(getAlertDestinationsDeliveryDismissConfirmation(count))) {
return;
}
setDismissingTerminalFailures(true);
try {
const result = await NotificationsAPI.dismissTerminalFailures();
notificationStore.success(
`${result.affected} retained ${result.affected === 1 ? 'failure' : 'failures'} dismissed.`,
);
await Promise.all([loadDeliveryHealth(), loadDeliveryLog()]);
} catch (error) {
logger.error('Failed to dismiss retained notification failures', error);
notificationStore.error('Unable to dismiss retained notification failures.');
} finally {
setDismissingTerminalFailures(false);
}
};
onMount(() => {
void loadDeliveryHealth();
void loadDeliveryLog();
@@ -150,6 +194,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLog,
deliveryLogUnavailable,
deliveryNeedsAttention,
dismissTerminalFailures,
dismissingTerminalFailures,
handleRetry,
hasLoadError,
isLoading,
@@ -157,6 +203,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
loadDeliveryLog,
refreshingDeliveryHealth,
refreshingDeliveryLog,
retryTerminalFailures,
retryingTerminalFailures,
testApprise,
testEmailConfig,
testingApprise,
@@ -42,7 +42,11 @@ import {
getAlertDestinationsConfigLoadError,
getAlertDestinationsDeliveryHealthDescription,
getAlertDestinationsDeliveryHealthTitle,
getAlertDestinationsDeliveryDismissConfirmation,
getAlertDestinationsDeliveryDismissLabel,
getAlertDestinationsDeliveryRefreshLabel,
getAlertDestinationsDeliveryRetryConfirmation,
getAlertDestinationsDeliveryRetryLabel,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsLoadErrorBanner,
@@ -170,6 +174,14 @@ describe('alertDestinationsPresentation', () => {
}),
).toContain('could not verify the notification queue');
expect(getAlertDestinationsDeliveryRefreshLabel()).toBe('Refresh delivery status');
expect(getAlertDestinationsDeliveryRetryLabel()).toBe('Retry retained deliveries');
expect(getAlertDestinationsDeliveryDismissLabel()).toBe('Dismiss retained failures');
expect(getAlertDestinationsDeliveryRetryConfirmation(1)).toContain(
'A destination that accepted an earlier attempt may receive a duplicate',
);
expect(getAlertDestinationsDeliveryDismissConfirmation(2)).toContain(
'Delivery history remains available',
);
});
});
@@ -153,6 +153,20 @@ describe('docsLinks', () => {
expect(importContract).toContain('settings:write');
});
it('ships the retained notification recovery API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
path.join(frontendRoot, 'public', 'docs', 'API.md'),
'utf8',
);
expect(shippedAPIReference).toBe(apiReference);
expect(apiReference).toContain('`POST /api/notifications/terminal-failures/retry`');
expect(apiReference).toContain('`POST /api/notifications/terminal-failures/dismiss`');
expect(apiReference).toContain('Existing per-attempt delivery history is preserved');
expect(apiReference).toContain('without deleting delivery history');
});
it('ships the truthful Patrol objective API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
@@ -79,6 +79,8 @@ export const ALERT_DESTINATIONS_DELIVERY_DEGRADED_TITLE = 'Notification delivery
export const ALERT_DESTINATIONS_DELIVERY_UNAVAILABLE_TITLE =
'Notification delivery status is unavailable';
export const ALERT_DESTINATIONS_DELIVERY_REFRESH_LABEL = 'Refresh delivery status';
export const ALERT_DESTINATIONS_DELIVERY_RETRY_LABEL = 'Retry retained deliveries';
export const ALERT_DESTINATIONS_DELIVERY_DISMISS_LABEL = 'Dismiss retained failures';
export function getAlertDestinationsConfigLoadError() {
return ALERT_DESTINATIONS_CONFIG_LOAD_ERROR;
@@ -199,6 +201,22 @@ export function getAlertDestinationsDeliveryRefreshLabel() {
return ALERT_DESTINATIONS_DELIVERY_REFRESH_LABEL;
}
export function getAlertDestinationsDeliveryRetryLabel() {
return ALERT_DESTINATIONS_DELIVERY_RETRY_LABEL;
}
export function getAlertDestinationsDeliveryDismissLabel() {
return ALERT_DESTINATIONS_DELIVERY_DISMISS_LABEL;
}
export function getAlertDestinationsDeliveryRetryConfirmation(count: number) {
return `Retry ${count} retained ${count === 1 ? 'delivery' : 'deliveries'} now? A destination that accepted an earlier attempt may receive a duplicate.`;
}
export function getAlertDestinationsDeliveryDismissConfirmation(count: number) {
return `Dismiss ${count} retained ${count === 1 ? 'failure' : 'failures'}? Pulse will clear the warning and will not retry them. Delivery history remains available.`;
}
// 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
+56 -2
View File
@@ -82,7 +82,7 @@ func (h *NotificationQueueHandlers) GetQueueStats(w http.ResponseWriter, r *http
// RetryDLQItem retries a specific notification from the DLQ
func (h *NotificationQueueHandlers) RetryDLQItem(w http.ResponseWriter, r *http.Request) {
if !apihttp.EnsureScope(w, r, config.ScopeMonitoringWrite) {
if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
@@ -129,7 +129,7 @@ func (h *NotificationQueueHandlers) RetryDLQItem(w http.ResponseWriter, r *http.
// DeleteDLQItem removes a notification from the DLQ permanently
func (h *NotificationQueueHandlers) DeleteDLQItem(w http.ResponseWriter, r *http.Request) {
if !apihttp.EnsureScope(w, r, config.ScopeMonitoringWrite) {
if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
@@ -174,6 +174,56 @@ func (h *NotificationQueueHandlers) DeleteDLQItem(w http.ResponseWriter, r *http
}
}
// RetryTerminalFailures returns all retained terminal failures to the queue
// after an operator has repaired the destination.
func (h *NotificationQueueHandlers) RetryTerminalFailures(w http.ResponseWriter, r *http.Request) {
if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
queue := h.monitor.GetNotificationManager().GetQueue()
if queue == nil {
http.Error(w, "Notification queue not initialized", http.StatusServiceUnavailable)
return
}
affected, err := queue.RetryTerminalFailures()
if err != nil {
log.Error().Err(err).Msg("Failed to retry retained terminal notifications")
http.Error(w, "Failed to retry retained notification failures", http.StatusInternalServerError)
return
}
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"success": true,
"affected": affected,
}); err != nil {
log.Error().Err(err).Msg("Failed to write terminal retry response")
}
}
// DismissTerminalFailures cancels all retained terminal queue rows without
// deleting their immutable delivery audit.
func (h *NotificationQueueHandlers) DismissTerminalFailures(w http.ResponseWriter, r *http.Request) {
if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
queue := h.monitor.GetNotificationManager().GetQueue()
if queue == nil {
http.Error(w, "Notification queue not initialized", http.StatusServiceUnavailable)
return
}
affected, err := queue.DismissTerminalFailures()
if err != nil {
log.Error().Err(err).Msg("Failed to dismiss retained terminal notifications")
http.Error(w, "Failed to dismiss retained notification failures", http.StatusInternalServerError)
return
}
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"success": true,
"affected": affected,
}); err != nil {
log.Error().Err(err).Msg("Failed to write terminal dismissal response")
}
}
// HandleNotificationQueue routes notification queue requests
func (h *NotificationQueueHandlers) HandleNotificationQueue(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@@ -187,6 +237,10 @@ func (h *NotificationQueueHandlers) HandleNotificationQueue(w http.ResponseWrite
h.RetryDLQItem(w, r)
case path == "/api/notifications/dlq/delete" && r.Method == http.MethodPost:
h.DeleteDLQItem(w, r)
case path == "/api/notifications/terminal-failures/retry" && r.Method == http.MethodPost:
h.RetryTerminalFailures(w, r)
case path == "/api/notifications/terminal-failures/dismiss" && r.Method == http.MethodPost:
h.DismissTerminalFailures(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)
}
@@ -116,6 +116,43 @@ func TestNotificationQueueHandlers_RetryAndDelete(t *testing.T) {
}
}
func TestNotificationQueueHandlers_BulkTerminalFailureRecovery(t *testing.T) {
handler, queue := newNotificationQueueHandlers(t)
enqueueDLQNotification(t, queue, "notif-bulk")
req := httptest.NewRequest(http.MethodPost, "/api/notifications/terminal-failures/retry", nil)
rec := httptest.NewRecorder()
handler.RetryTerminalFailures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("RetryTerminalFailures status = %d, want 200: %s", rec.Code, rec.Body.String())
}
var response struct {
Affected int `json:"affected"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode retry response: %v", err)
}
if response.Affected != 1 {
t.Fatalf("retry affected = %d, want 1", response.Affected)
}
if err := queue.UpdateStatus("notif-bulk", notifications.QueueStatusDLQ, "still unavailable"); err != nil {
t.Fatalf("mark retried notification DLQ: %v", err)
}
req = httptest.NewRequest(http.MethodPost, "/api/notifications/terminal-failures/dismiss", nil)
rec = httptest.NewRecorder()
handler.DismissTerminalFailures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("DismissTerminalFailures status = %d, want 200: %s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode dismiss response: %v", err)
}
if response.Affected != 1 {
t.Fatalf("dismiss affected = %d, want 1", response.Affected)
}
}
func TestNotificationQueueHandlers_HandleNotificationQueue(t *testing.T) {
handler, queue := newNotificationQueueHandlers(t)
enqueueDLQNotification(t, queue, "notif-3")
+2
View File
@@ -594,6 +594,8 @@ var allRouteAllowlist = []string{
"/api/notifications/queue/stats",
"/api/notifications/dlq/retry",
"/api/notifications/dlq/delete",
"/api/notifications/terminal-failures/retry",
"/api/notifications/terminal-failures/dismiss",
"/api/system/settings",
"/api/system/settings/update",
"/api/system/ssh-config",
+14
View File
@@ -335,6 +335,20 @@ func (r *Router) registerMonitoringResourceRoutes(
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/notifications/terminal-failures/retry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.notificationQueueHandlers.RetryTerminalFailures(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/notifications/terminal-failures/dismiss", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.notificationQueueHandlers.DismissTerminalFailures(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
// AI-powered infrastructure discovery endpoints
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))
r.mux.HandleFunc("/api/discovery/status", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetStatus)))
+4
View File
@@ -508,6 +508,8 @@ func TestNotificationsDLQMutationsRequireSettingsWriteScope(t *testing.T) {
cases := []string{
"/api/notifications/dlq/retry",
"/api/notifications/dlq/delete",
"/api/notifications/terminal-failures/retry",
"/api/notifications/terminal-failures/dismiss",
}
for _, path := range cases {
@@ -2897,6 +2899,8 @@ func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) {
{method: http.MethodGet, path: "/api/notifications/dlq", body: ""},
{method: http.MethodPost, path: "/api/notifications/dlq/retry", body: `{}`},
{method: http.MethodPost, path: "/api/notifications/dlq/delete", body: `{}`},
{method: http.MethodPost, path: "/api/notifications/terminal-failures/retry", body: `{}`},
{method: http.MethodPost, path: "/api/notifications/terminal-failures/dismiss", body: `{}`},
}
for _, tc := range cases {
+116
View File
@@ -1278,6 +1278,122 @@ func (nq *NotificationQueue) GetDLQ(limit int) ([]*QueuedNotification, error) {
return notifications, rows.Err()
}
// RetryTerminalFailures returns every retained failed or dead-lettered
// delivery to the pending queue with a fresh retry budget. The per-attempt
// audit rows remain intact, so this operator recovery never rewrites delivery
// history or makes an earlier failure look successful.
func (nq *NotificationQueue) RetryTerminalFailures() (int, error) {
return nq.resolveTerminalFailures(true)
}
// DismissTerminalFailures marks every retained failed or dead-lettered
// delivery as cancelled. This clears the active queue-health warning while
// retaining the immutable delivery audit instead of asking operators to
// delete the queue database.
func (nq *NotificationQueue) DismissTerminalFailures() (int, error) {
return nq.resolveTerminalFailures(false)
}
func (nq *NotificationQueue) resolveTerminalFailures(retry bool) (int, error) {
if nq == nil || nq.db == nil {
return 0, fmt.Errorf("notification queue not initialized")
}
nq.mu.Lock()
defer nq.mu.Unlock()
rows, err := nq.db.Query(`
SELECT id, operational_links
FROM notification_queue
WHERE status IN ('failed', 'dlq')
ORDER BY completed_at, id
`)
if err != nil {
return 0, fmt.Errorf("read retained terminal notifications: %w", err)
}
type terminalNotification struct {
id string
links []operationaltrust.NotificationLink
}
terminal := make([]terminalNotification, 0)
for rows.Next() {
var id, rawLinks string
if err := rows.Scan(&id, &rawLinks); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("scan retained terminal notification: %w", err)
}
var links []operationaltrust.NotificationLink
if err := json.Unmarshal([]byte(rawLinks), &links); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("decode retained terminal notification links: %w", err)
}
terminal = append(terminal, terminalNotification{id: id, links: links})
}
if err := rows.Close(); err != nil {
return 0, fmt.Errorf("close retained terminal notification rows: %w", err)
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("iterate retained terminal notifications: %w", err)
}
if len(terminal) == 0 {
return 0, nil
}
tx, err := nq.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin terminal notification recovery: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := time.Now()
affected := 0
for _, notification := range terminal {
state := operationaltrust.NotificationCancelled
if retry {
state = operationaltrust.NotificationRetrying
}
linksJSON, err := json.Marshal(transitionNotificationLinks(notification.links, state, now))
if err != nil {
return 0, fmt.Errorf("encode terminal notification links: %w", err)
}
var result sql.Result
if retry {
result, err = tx.Exec(`
UPDATE notification_queue
SET status = 'pending', attempts = 0, last_attempt = NULL,
last_error = NULL, next_retry_at = ?, completed_at = NULL,
operational_links = ?
WHERE id = ? AND status IN ('failed', 'dlq')
`, now.Unix(), string(linksJSON), notification.id)
} else {
result, err = tx.Exec(`
UPDATE notification_queue
SET status = 'cancelled', last_attempt = ?,
last_error = 'Dismissed by operator', next_retry_at = NULL,
completed_at = ?, operational_links = ?
WHERE id = ? AND status IN ('failed', 'dlq')
`, now.Unix(), now.Unix(), string(linksJSON), notification.id)
}
if err != nil {
return 0, fmt.Errorf("resolve terminal notification %s: %w", notification.id, err)
}
if count, countErr := result.RowsAffected(); countErr == nil {
affected += int(count)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit terminal notification recovery: %w", err)
}
if retry && affected > 0 {
select {
case nq.notifyChan <- struct{}{}:
default:
}
}
return affected, nil
}
// RecordAudit records a notification delivery attempt in the audit log
func (nq *NotificationQueue) RecordAudit(notif *QueuedNotification, success bool, errorMsg string) error {
nq.mu.Lock()
+67
View File
@@ -1381,6 +1381,73 @@ func TestGetQueueStats(t *testing.T) {
}
})
t.Run("operator recovery retries or dismisses every terminal row without deleting history", func(t *testing.T) {
nq, err := NewNotificationQueue(t.TempDir())
if err != nil {
t.Fatalf("NewNotificationQueue: %v", err)
}
defer func() { _ = nq.Stop() }()
for _, notif := range []*QueuedNotification{
{ID: "failed-recovery", Type: "email", Status: QueueStatusPending, Attempts: 8, MaxAttempts: 8, Config: []byte(`{}`)},
{ID: "dlq-recovery", Type: "webhook", Status: QueueStatusPending, Attempts: 8, MaxAttempts: 8, Config: []byte(`{}`)},
{ID: "pending-unrelated", Type: "email", Status: QueueStatusPending, MaxAttempts: 8, Config: []byte(`{}`)},
} {
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("enqueue %s: %v", notif.ID, err)
}
}
if err := nq.UpdateStatus("failed-recovery", QueueStatusFailed, "authentication failed"); err != nil {
t.Fatalf("mark failed: %v", err)
}
if err := nq.UpdateStatus("dlq-recovery", QueueStatusDLQ, "retries exhausted"); err != nil {
t.Fatalf("mark dlq: %v", err)
}
affected, err := nq.RetryTerminalFailures()
if err != nil {
t.Fatalf("RetryTerminalFailures: %v", err)
}
if affected != 2 {
t.Fatalf("retry affected = %d, want 2", affected)
}
for _, id := range []string{"failed-recovery", "dlq-recovery"} {
var status string
var attempts int
var lastAttempt, lastError, completedAt *int64
if err := nq.db.QueryRow(`
SELECT status, attempts, last_attempt, last_error, completed_at
FROM notification_queue WHERE id = ?
`, id).Scan(&status, &attempts, &lastAttempt, &lastError, &completedAt); err != nil {
t.Fatalf("read retried %s: %v", id, err)
}
if status != string(QueueStatusPending) || attempts != 0 || lastAttempt != nil || lastError != nil || completedAt != nil {
t.Fatalf("retried %s = status %q attempts %d lastAttempt %v lastError %v completedAt %v", id, status, attempts, lastAttempt, lastError, completedAt)
}
}
if err := nq.UpdateStatus("failed-recovery", QueueStatusFailed, "still invalid"); err != nil {
t.Fatalf("mark failed again: %v", err)
}
if err := nq.UpdateStatus("dlq-recovery", QueueStatusDLQ, "still unavailable"); err != nil {
t.Fatalf("mark dlq again: %v", err)
}
affected, err = nq.DismissTerminalFailures()
if err != nil {
t.Fatalf("DismissTerminalFailures: %v", err)
}
if affected != 2 {
t.Fatalf("dismiss affected = %d, want 2", affected)
}
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats: %v", err)
}
if stats[string(QueueStatusFailed)] != 0 || stats[string(QueueStatusDLQ)] != 0 || stats[string(QueueStatusCancelled)] != 2 || stats[string(QueueStatusPending)] != 1 {
t.Fatalf("stats after dismissal = %#v", stats)
}
})
t.Run("UpdateStatus returns error for non-existent notification", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)