fix(alerts): retain notification recovery failure feedback

Recovery failures otherwise disappear with their toast, leaving slower readers without the action outcome. Keep a view-local untimed equivalent on Overview and Notifications until clear or a newer confirmed action, independently of queue health.

Separate accepted queue mutations from optional activity-refresh failures. Cover ownership and cancellation in focused tests, and verify both real views with scripted APIs in Chromium at three widths and both themes. This implements the current main-only recovery feedback bet, not a backend delivery fix or release-line backport.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-07 11:44:50 +01:00
parent e949c15c3d
commit 036d324460
27 changed files with 668 additions and 35 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,66 @@
# Recovery feedback qualification — 7 September 2026
Run from the repository root:
```sh
pulse-heavy-run -- node scripts/check-recovery-feedback.mjs
```
The final run passed 12 cases: real OverviewTab and DestinationsTab, shared
Card/Button/ToastContainer, 1440/900/390px, light and dark. Source digests and
the result are adjacent; the exact changed-source receipt is
frontend-modern/browser-verification.json. Screenshots show retained feedback
after the final toast expires. Representative narrow, intermediate and desktop
screenshots were inspected for readable feedback and reachable controls.
The harness exercises keyboard Retry/Dismiss and native confirmation,
cancellation without mutation, superseding actions, ten-second error-toast
expiry (accelerated browser clock plus exit-animation timer), successful
mutation followed by failed health read, subsequent health refresh, and
keyboard clear with retained focus. On Destinations a healthy refresh removes
the health warning without removing action-failure information. Unit tests
add healthy reconciliation independently of the available Overview controls,
callback throw/rejection, and older completion versus clear/newer ownership.
54 focused tests passed across useNotificationDeliveryHealth,
AlertDeliveryHealthCard, useAlertDestinationsTabState,
OverviewTab.deliveryactions and OverviewTab.emptystate. TypeScript and
changed-file ESLint passed. No full suite, build, installed notification
delivery, recipient receipt, app-shell navigation, or screen-reader testing
is claimed.
## Failed harness attempts, retained rather than counted as passes
1. Initial run timed out waiting for Retry. The API route stub matched
/src/api modules as well as application API requests, leaving a blank page.
2. Diagnostic rerun confirmed an empty body and no pageerror event; failed.png
retains that image. The in-flight script had loaded before the correction.
3. With the route constrained to the fixture origin's /api/ prefix, Overview
rendered and passed, but the destination Refresh selector matched both
health and activity controls.
4. A remaining healthy-reconciliation Refresh selector had the same ambiguity.
Both selectors now identify the health card, not the first matching button.
5. The full destination render exposed a missing required email 'to' array in
the fixture (undefined.join). The fixture now supplies complete email and
Apprise settings and a correctly shaped empty delivery log.
The final rerun passed all cases with zero page errors. No runtime source was
changed to suppress these harness failures.
## Evidence and boundaries
The current demand-ledger named bet, “Recovery feedback without a reading
deadline” (pulse-pro FEATURE_REQUESTS.md, introduced by 9c25eaf1), authorises
this main-only scope. Fresh W3C guidance read on 7 September distinguishes
temporary information with an untimed equivalent from information lost at
expiry:
https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html
Its status technique supports an existing status container and explicit
atomic announcement semantics:
https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22
This is not a WCAG verdict or a fix for the reporter's HTTP503. Feedback is
view-local: clear, a newer confirmed action, leaving the view or reload can
remove it. Queue acceptance is not delivery receipt. The optional activity
callback is tested separately; the current destination log loader already
handles its own read failures.
@@ -0,0 +1,10 @@
{
"result": "passed",
"cases": 12,
"viewports": [
1440,
900,
390
],
"scope": "Real OverviewTab and DestinationsTab, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,8 @@
{
"frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx": "4fd4e2757b11bc4adb52bad293eb5a7a8d55301e12e5f4c3816fdbe0682bc578",
"frontend-modern/src/features/alerts/OverviewTab.tsx": "6700ce02ad19b4133860a7b680ed145e21ce27fa7fd4053937ce9cd3fc0c02e1",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "968d6d3f82d59235a13bfcd89b8405558d0286c89a22efc12bbc7e2ff5ad1600",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "25d47c0ce64f433ce0fd8ca851434d3c09441970d1e929de8c7787f209ab1ddb",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts": "863e1b167bc27df5d46976b704a883ba6df9066bb6e42133935dbaa2c02f0b03",
"scripts/check-recovery-feedback.mjs": "1cf11b716b9a65c710f16e4765645208663cfe98dd26b87bd953e5309a3b6c4b"
}
@@ -15,6 +15,34 @@
## Purpose
### Retained-queue recovery feedback has no reading deadline
Retry and Dismiss failures have a view-local untimed equivalent beside the
delivery-health card on Overview and Notifications. A health refresh, including
a verified healthy result that removes the warning, does not erase a rejected
action's message. Cancellation preserves it. Deliberate clear or the next
confirmed action supersedes it; reloading or leaving the view resets it.
The existing error toast remains transient.
An accepted queue mutation still emits success if the health read fails.
That read owns the existing unavailable warning. A synchronous throw or rejected
promise from the optional activity-refresh callback must not claim the mutation
failed: retained feedback explicitly distinguishes accepted action from failed
activity refresh. Older action/callback completions cannot restore feedback
after a clear or overwrite a newer action's message. Neither acceptance nor a
healthy queue proves intended-recipient receipt; no retry policy changes.
Proof: useNotificationDeliveryHealth.test.tsx pins expiry-independent state,
health reconciliation, cancellation, callback failure and ownership;
AlertDeliveryHealthCard.test.tsx pins mounted live-region composition and clear
focus. scripts/check-recovery-feedback.mjs exercises the real Overview and
destination views with scripted API responses, real toast expiry, keyboard
actions/clear, unavailable-to-healthy reconciliation and three viewport widths.
This is the main-only named bet “Recovery feedback without a reading deadline”
from the current demand ledger, not a backend failure diagnosis or backport.
Overview delivery diagnoses use latest-started refresh ownership. Older bulk
responses cannot overwrite newer card notification status, and an empty active
alert set invalidates outstanding reads. Disposal also prevents updates. Failed
@@ -20,6 +20,26 @@
## Purpose
### Recovery feedback composes shared controls without a timer
The alerts-owned AlertQueueActionFeedback composes Card and Button rather than
altering global toast lifetimes. Its polite atomic status region is mounted
before failure text arrives; the warning card uses semantic foreground and wraps
text and the explicit “Clear recovery message” control at narrow widths.
The enclosing labelled region remains mounted and receives focus before clear
removes the button, avoiding focus loss to the document body. Updating feedback
does not steal focus. This is view-local feedback, not durable delivery history.
The live region stays independent of delivery-health conditional rendering:
a later healthy observation can remove the health warning without removing
failed-action information. AlertDeliveryHealthCard.test.tsx verifies status
and focus composition. scripts/check-recovery-feedback.mjs verifies both real
feature views, keyboard activation/clear and text/control containment at 1440,
900 and 390px, with scripted responses and genuine toast expiry. It does not
establish screen-reader announcement quality or installed notification delivery.
Disk I/O presentation preserves each observed direction independently. Shared
formatting renders a missing rate as a dash and measured idle as numeric zero.
Partial observations cannot form a complete throughput total for sorting or
+27 -33
View File
@@ -1,25 +1,25 @@
{
"version": 1,
"base_sha": "560dbf314c4fc3744f52aa4e5a6a204cafe3aa7d",
"verified_at": "2026-09-07T08:40:47Z",
"base_sha": "e949c15c3d0e751e6cd7b1d8d46a1147840ae3c8",
"verified_at": "2026-09-07T10:44:07Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/AI/Chat/index.tsx",
"frontend-modern/src/components/AI/FindingsPanel.tsx",
"frontend-modern/src/components/patrol/ApprovalSection.tsx",
"frontend-modern/src/types/resource.ts"
"frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx",
"frontend-modern/src/features/alerts/OverviewTab.tsx",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts"
],
"content_sha256": {
"frontend-modern/src/components/AI/Chat/index.tsx": "a31459ed9eeda0e96a192c0b6b471746c1a6da70cd8c68ab75b833ca763071f8",
"frontend-modern/src/components/AI/FindingsPanel.tsx": "92f1fd73740cb71c868c2d7054323d43f93ebc53dbb5cacdc1f828c5c2f2a868",
"frontend-modern/src/components/patrol/ApprovalSection.tsx": "b04e131f5a82d538f37aa9cb3aadaf0f7303266cfc318ff04a42a8f7f6a4f6ba",
"frontend-modern/src/types/resource.ts": "54217a487ce295d433a9e016bf655ab8572a903383ae0516fdd52e5004524ea7"
"frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx": "4fd4e2757b11bc4adb52bad293eb5a7a8d55301e12e5f4c3816fdbe0682bc578",
"frontend-modern/src/features/alerts/OverviewTab.tsx": "6700ce02ad19b4133860a7b680ed145e21ce27fa7fd4053937ce9cd3fc0c02e1",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "968d6d3f82d59235a13bfcd89b8405558d0286c89a22efc12bbc7e2ff5ad1600",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "25d47c0ce64f433ce0fd8ca851434d3c09441970d1e929de8c7787f209ab1ddb",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts": "863e1b167bc27df5d46976b704a883ba6df9066bb6e42133935dbaa2c02f0b03"
},
"routes": [
"/patrol",
"/actions?action=act_185eebf5dfc24652e6332881dbe94f6c",
"/actions?action=act_7687850d214e9c5f7038a436c6cc2ff7",
"/actions?action=act_e5e1f06da3dc42215a97360731d7e36b"
"/qualification/overview",
"/qualification/destinations"
],
"viewports": [
{
@@ -36,26 +36,20 @@
}
],
"states": [
"Activity with All findings including resolved records",
"Expired action with needs-attention investigation and retained linked action",
"Completed action with independent verification",
"Rejected action without execution controls",
"Expanded investigation thread with raw filesystem evidence and nested output scroll",
"Assistant empty conversation with attached finding",
"New Assistant conversation with context cleared and ordinary starters restored",
"Existing Assistant conversation loaded from recent sessions",
"Reloaded exact action review"
"Rejected Retry and Dismiss with untimed feedback after real toast timers expire using the Playwright clock",
"Cancelled action retains prior feedback without another API mutation",
"Accepted mutation with failed health read preserves unavailable warning and does not report mutation failure",
"Newer confirmed action supersedes prior feedback",
"Destination healthy refresh removes health warning but retains failed-action information",
"Deliberate clear removes feedback and retains focus in its labelled region",
"Light and dark themes at desktop, intermediate and narrow widths"
],
"interactions": [
"Keyboard focus and Enter open the finding review and investigation thread",
"Expand tool results and scroll inside the native filesystem observation output",
"Discuss with Assistant retains the finding and expired action context without unrelated starters",
"Start new Assistant session clears context and restores welcome and recent sessions",
"Load existing session and inspect its transcript",
"Follow exact Actions links, inspect planned state and recorded independent verification",
"Expand policy evidence, evidence details and delivery identifiers with keyboard",
"Reload exact action URLs, close with Escape and the explicit close button",
"Inspect desktop, intermediate and narrow placement, nested scrolling, footer reachability and document overflow"
"Focus Retry and Dismiss and activate with Enter; accept or cancel native confirmation",
"Advance browser clock beyond ten-second error duration and complete toast exit timer",
"Refresh unavailable health; reconcile destination health to healthy",
"Focus and activate Clear recovery message using Enter",
"Assert feedback text and controls fit viewport; inspect representative screenshots"
],
"notes": "Real bundled development runtime SHA256 1f1f71d2fd77b89f43a980d1e990a4010d2ec467502551555d5ab702f1aa71e6. Playwright scripts and screenshots are in workspace tmp/patrol-filesystem-evidence/{storage-browser,action-browser}. Final pixels were reviewed after transitions settled. Browser proof covers the named changes, not full product readiness. Independent qualification still fails missing-access continuity and VM execution. An existing Assistant verification-policy message leaking into historical transcript is retained as a separate canonical orchestration gap. Mock mode is off. Browser scripts allow only login and selected-provider readiness POSTs. Repeated after the shared incremental alert-history change. Eighteen bounded concurrent attention-summary requests completed and the final goroutine check found no history walkers. This functional check is not a workstation performance benchmark."
"notes": "Real source OverviewTab and DestinationsTab with shared Card, Button and ToastContainer in isolated Vite/Chromium fixture; scripted APIs, not full app-shell routing, installed backend delivery or intended-recipient receipt. Callback throw/rejection and stale completion ownership are unit-tested, not browser-injected. No screen-reader reproduction or WCAG conformance claim. Evidence and five unsuccessful harness attempts documented in docs/qualification/recovery-feedback/README.md. Final run: 12 cases, zero page errors. Runtime source was unchanged throughout harness repairs."
}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { NotificationQueueHealth } from '@/api/notifications';
import { AlertQueueActionFeedback } from './AlertQueueActionFeedback';
import { AlertDeliveryHealthCard } from './AlertDeliveryHealthCard';
const degradedHealth: NotificationQueueHealth = {
@@ -215,3 +216,19 @@ describe('AlertDeliveryHealthCard', () => {
);
});
});
describe('recovery feedback beside delivery health', () => {
afterEach(() => cleanup());
it('keeps a mounted live region and returns focus to it when deliberately cleared', () => {
const [message, setMessage] = createSignal<string | null>(null);
render(() => <AlertQueueActionFeedback message={message()} onClear={() => setMessage(null)} />);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
setMessage('Unable to retry retained notification deliveries.');
expect(screen.getByRole('status')).toHaveTextContent('Unable to retry');
const clear = screen.getByRole('button', { name: 'Clear recovery message' });
clear.focus();
fireEvent.click(clear);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
expect(screen.getByRole('region', { name: 'Notification recovery feedback' })).toHaveFocus();
});
});
@@ -0,0 +1,34 @@
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Button } from '@/components/shared/Button';
// Keep the live region mounted before its content changes, and retain a focus
// destination when the user clears the message. This is view-local, not history.
export function AlertQueueActionFeedback(props: { message: string | null; onClear: () => void }) {
let region!: HTMLDivElement;
return (
<div ref={region} role="region" aria-label="Notification recovery feedback" tabIndex={-1}>
<div role="status" aria-live="polite" aria-atomic="true">
<Show when={props.message}>
<Card tone="warning" padding="sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="min-w-0 flex-1 basis-72 break-words text-sm text-base-content">
{props.message}
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
region.focus();
props.onClear();
}}
>
Clear recovery message
</Button>
</div>
</Card>
</Show>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { AlertQueueActionFeedback } from './AlertQueueActionFeedback';
import { createSignal, onCleanup, createEffect, onMount, Show } from 'solid-js';
import { useLocation } from '@solidjs/router';
@@ -78,6 +79,10 @@ export function OverviewTab(props: {
return (
<div class="space-y-4 sm:space-y-6">
<AlertQueueActionFeedback
message={deliveryHealthState.queueActionFeedback()}
onClear={deliveryHealthState.clearQueueActionFeedback}
/>
<Show when={deliveryHealthState.deliveryNeedsAttention()}>
<AlertDeliveryHealthCard
health={deliveryHealthState.deliveryHealth()?.queue ?? null}
@@ -276,4 +276,148 @@ describe('useNotificationDeliveryHealth', () => {
}),
);
});
describe.each(['dismissTerminalFailures', 'retryTerminalFailures'] as const)(
'%s feedback',
(action) => {
it('retains failed action information across time, healthy reads and cancellation until cleared or superseded', () =>
createRoot(async (dispose) => {
vi.useFakeTimers();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(new Error('failed'));
const state = useNotificationDeliveryHealth();
await state.loadDeliveryHealth();
await state[action]();
const failure = state.queueActionFeedback();
expect(failure).toMatch(/^Unable to/);
await vi.advanceTimersByTimeAsync(11000);
vi.mocked(NotificationsAPI.getHealth).mockResolvedValueOnce(healthWith('healthy'));
await state.loadDeliveryHealth();
expect(state.deliveryNeedsAttention()).toBe(false);
expect(state.queueActionFeedback()).toBe(failure);
await state.loadDeliveryHealth();
confirmSpy.mockReturnValueOnce(false);
await state[action]();
expect(state.queueActionFeedback()).toBe(failure);
state.clearQueueActionFeedback();
expect(state.queueActionFeedback()).toBeNull();
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(new Error('again'));
await state[action]();
expect(state.queueActionFeedback()).toBe(failure);
vi.mocked(NotificationsAPI[action]).mockResolvedValueOnce({ affected: 2 } as never);
await state[action]();
expect(state.queueActionFeedback()).toBeNull();
} finally {
vi.useRealTimers();
confirmSpy.mockRestore();
dispose();
}
}));
it.each(['throw', 'reject'] as const)(
'does not misreport an accepted mutation when the activity callback fails: %s',
(failure) =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockResolvedValue({ affected: 2 } as never);
const onAfterQueueAction = () => {
if (failure === 'throw') throw new Error('activity failed');
return Promise.reject(new Error('activity failed'));
};
const state = useNotificationDeliveryHealth({ onAfterQueueAction });
await state.loadDeliveryHealth();
await state[action]();
expect(notificationStore.success).toHaveBeenCalledOnce();
expect(notificationStore.error).not.toHaveBeenCalled();
expect(state.queueActionFeedback()).toMatch(
/^The queue action succeeded, but notification activity/,
);
expect(state.retryingTerminalFailures()).toBe(false);
expect(state.dismissingTerminalFailures()).toBe(false);
} finally {
confirmSpy.mockRestore();
dispose();
}
}),
);
it.each(['clear', 'newer'] as const)(
'does not restore stale callback feedback after %s',
(next) =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockResolvedValue({ affected: 2 } as never);
let rejectOld!: (error: Error) => void;
const onAfterQueueAction = vi.fn().mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectOld = reject;
}),
);
const state = useNotificationDeliveryHealth({ onAfterQueueAction });
await state.loadDeliveryHealth();
const pending = state[action]();
await Promise.resolve();
await Promise.resolve();
if (next === 'clear') state.clearQueueActionFeedback();
else {
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(
new Error('new action failed'),
);
await state[action]();
}
const current = state.queueActionFeedback();
rejectOld(new Error('old activity failed'));
await pending;
expect(state.queueActionFeedback()).toBe(current);
if (next === 'newer') expect(current).toMatch(/^Unable to/);
} finally {
confirmSpy.mockRestore();
dispose();
}
}),
);
},
);
it('does not let an older rejected mutation overwrite the newer action failure', () =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
let rejectOld!: (error: Error) => void;
vi.mocked(NotificationsAPI.retryTerminalFailures).mockReturnValueOnce(
new Promise((_, reject) => {
rejectOld = reject;
}),
);
vi.mocked(NotificationsAPI.dismissTerminalFailures).mockRejectedValueOnce(
new Error('new failure'),
);
const state = useNotificationDeliveryHealth();
await state.loadDeliveryHealth();
const old = state.retryTerminalFailures();
await state.dismissTerminalFailures();
rejectOld(new Error('old failure'));
await old;
expect(state.queueActionFeedback()).toBe(
'Unable to dismiss retained notification failures.',
);
} finally {
confirmSpy.mockRestore();
dispose();
}
}));
});
@@ -1,3 +1,4 @@
import { AlertQueueActionFeedback } from '../AlertQueueActionFeedback';
import { createMemo, createSignal, Show } from 'solid-js';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
@@ -79,6 +80,10 @@ export function DestinationsTab(props: DestinationsTabProps) {
)}
</Show>
<AlertQueueActionFeedback
message={state.queueActionFeedback()}
onClear={state.clearQueueActionFeedback}
/>
<Show when={state.deliveryNeedsAttention()}>
<AlertDeliveryHealthCard
health={state.deliveryHealth()?.queue ?? null}
@@ -36,6 +36,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const {
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
refreshingDeliveryHealth,
@@ -163,6 +165,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
return {
appriseState,
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
deliveryLog,
@@ -55,6 +55,33 @@ export function useNotificationDeliveryHealth(options?: {
(deliveryHealthUnavailable() || deliveryHealth()?.queue.status === 'degraded'),
);
// Action failure is independent of current queue health. A healthy read is
// not evidence that a previously rejected action succeeded.
const [queueActionFeedback, setQueueActionFeedback] = createSignal<string | null>(null);
let latestAction = 0;
const clearQueueActionFeedback = () => {
++latestAction;
setQueueActionFeedback(null);
};
const refreshAfterAction = async (action: number) => {
await Promise.all([
loadDeliveryHealth(),
Promise.resolve()
.then(() => options?.onAfterQueueAction?.())
.catch((error) => {
logger.error(
'Failed to refresh notification activity after accepted queue action',
error,
);
if (action === latestAction) {
setQueueActionFeedback(
'The queue action succeeded, but notification activity could not be refreshed. Reload this view to check activity.',
);
}
}),
]);
};
const [retryingTerminalFailures, setRetryingTerminalFailures] = createSignal(false);
const [dismissingTerminalFailures, setDismissingTerminalFailures] = createSignal(false);
@@ -63,15 +90,19 @@ export function useNotificationDeliveryHealth(options?: {
if (count <= 0 || !confirm(getAlertDestinationsDeliveryRetryConfirmation(count))) {
return;
}
const action = ++latestAction;
setQueueActionFeedback(null);
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(), Promise.resolve(options?.onAfterQueueAction?.())]);
await refreshAfterAction(action);
} catch (error) {
logger.error('Failed to retry retained notification deliveries', error);
if (action === latestAction)
setQueueActionFeedback('Unable to retry retained notification deliveries.');
notificationStore.error('Unable to retry retained notification deliveries.');
} finally {
setRetryingTerminalFailures(false);
@@ -83,15 +114,19 @@ export function useNotificationDeliveryHealth(options?: {
if (count <= 0 || !confirm(getAlertDestinationsDeliveryDismissConfirmation(count))) {
return;
}
const action = ++latestAction;
setQueueActionFeedback(null);
setDismissingTerminalFailures(true);
try {
const result = await NotificationsAPI.dismissTerminalFailures();
notificationStore.success(
`${result.affected} retained ${result.affected === 1 ? 'failure' : 'failures'} dismissed.`,
);
await Promise.all([loadDeliveryHealth(), Promise.resolve(options?.onAfterQueueAction?.())]);
await refreshAfterAction(action);
} catch (error) {
logger.error('Failed to dismiss retained notification failures', error);
if (action === latestAction)
setQueueActionFeedback('Unable to dismiss retained notification failures.');
notificationStore.error('Unable to dismiss retained notification failures.');
} finally {
setDismissingTerminalFailures(false);
@@ -99,6 +134,8 @@ export function useNotificationDeliveryHealth(options?: {
};
return {
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
refreshingDeliveryHealth,
+261
View File
@@ -0,0 +1,261 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { AlertsAPI } from '/src/api/alerts';
import { NotificationsAPI } from '/src/api/notifications';
import { OverviewTab } from '/src/features/alerts/OverviewTab';
import { DestinationsTab } from '/src/features/alerts/tabs/DestinationsTab';
import { ToastContainer } from '/src/components/Toast/Toast';
import '/src/index.css';
window.health = 'degraded'; window.action = 'reject';
window.mutations = 0;
NotificationsAPI.getHealth = async () => {
if(window.health === 'error') throw new Error('scripted offline');
return {queue:{status:window.health, attentionRequired:window.health==='healthy'?0:2, failed:2,deadLetter:0}};
};
NotificationsAPI.retryTerminalFailures = NotificationsAPI.dismissTerminalFailures = async () => {
window.mutations++;
if(window.action==='reject') throw new Error('scripted rejected');
return {affected:2};
};
NotificationsAPI.getDeliveryLog = async () => ({entries:[],windowDays:7,completedRetentionDays:7,deadLetterRetentionDays:30});
NotificationsAPI.getWebhooks = async () => [];
AlertsAPI.getEvents = async () => [];
AlertsAPI.getDeliveryDiagnoses = async () => [];
const noop = () => {};
function Overview() {return <OverviewTab overrides={[]} activeAlerts={{}} updateAlert={noop}
showQuickTip={()=>false} dismissQuickTip={noop} showAcknowledged={()=>true}
setShowAcknowledged={noop} alertsDisabled={()=>false}/>;}
function Destinations() {return <DestinationsTab
emailConfig={()=>({enabled:false,provider:'smtp',from:'',server:'',username:'',password:'',port:587,to:[],tls:true,startTLS:true,replyTo:'',maxRetries:3,retryDelay:5,rateLimit:60})} setEmailConfig={noop}
appriseConfig={()=>({enabled:false,mode:'cli',targetsText:'',configKey:'',serverUrl:'',timeoutSeconds:30,apiKey:'',apiKeyHeader:'X-API-KEY',hasApiKey:false,skipTlsVerify:false,cliPath:'apprise'})} setAppriseConfig={noop}
configLoadError={()=>null} isRetrying={()=>false} isLoadingDestinations={()=>false} onRetryLoad={noop}
webhooks={()=>[]} setHasUnsavedChanges={noop} deadManPingUrl={()=>''} setDeadManPingUrl={noop}
pushMinimumSeverity={()=>'all'} setPushMinimumSeverity={noop}/>;}
function Fixture() {return <main class="p-4"><ToastContainer/>{location.pathname.endsWith('destinations')?<Destinations/>:<Overview/>}</main>;}
render(()=><Router><Route path="/qualification/*" component={Fixture}/></Router>,document.getElementById('root'));
`;
const server = await createServer({
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "recovery-fixture",
configureServer(s) {
s.middlewares.use((req, res, next) => {
if (req.url?.startsWith("/qualification")) {
res.setHeader("Content-Type", "text/html");
res.end(
'<div id="root"></div><script type="module" src="/recovery-fixture.tsx"></script>',
);
} else next();
});
},
resolveId(id) {
if (id === "/recovery-fixture.tsx") return id;
},
load(id) {
if (id === "/recovery-fixture.tsx") return fixture;
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
server: { host: "127.0.0.1", port: 5199, strictPort: true },
});
let browser;
const output = resolve("../docs/qualification/recovery-feedback");
try {
await server.listen();
browser = await chromium.launch({ headless: true });
mkdirSync(output, { recursive: true });
let cases = 0;
for (const width of [1440, 900, 390])
for (const surface of ["overview", "destinations"])
for (const theme of ["light", "dark"]) {
const page = await browser.newPage({
viewport: { width, height: 1000 },
});
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("http://127.0.0.1:5199/api/**", (route) =>
route.fulfill({ json: [] }),
);
await page.goto("http://127.0.0.1:5199/qualification/" + surface);
await page.evaluate(
(theme) =>
document.documentElement.classList.toggle("dark", theme === "dark"),
theme,
);
const feedback = page.getByRole("region", {
name: "Notification recovery feedback",
});
const retry = page.getByRole("button", {
name: "Retry retained deliveries",
exact: true,
});
const dismiss = page.getByRole("button", {
name: "Dismiss retained failures",
exact: true,
});
const healthCard = page.getByRole("alert").filter({ has: retry });
let accept = true;
page.on("dialog", (dialog) =>
accept ? dialog.accept() : dialog.dismiss(),
);
await retry.waitFor().catch(async (error) => {
console.error(
JSON.stringify({
surface,
width,
theme,
errors,
body: await page.locator("body").innerText(),
}),
);
await page.screenshot({
path: resolve(output, "failed.png"),
fullPage: true,
});
throw error;
});
await page.clock.install();
// Keyboard confirmation, genuine toast expiry, durable equivalent.
await retry.focus();
await page.keyboard.press("Enter");
await feedback
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.waitFor();
await page.clock.fastForward(12000);
await page.clock.runFor(350);
assert.match(await feedback.innerText(), /Unable to retry/);
assert.equal(
await page
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.count(),
1,
);
// Cancellation leaves the previous message and does not submit.
const before = await page.evaluate(() => window.mutations);
accept = false;
await dismiss.click();
assert.equal(await page.evaluate(() => window.mutations), before);
assert.match(await feedback.innerText(), /Unable to retry/);
accept = true;
await dismiss.focus();
await page.keyboard.press("Enter");
await feedback
.getByText("Unable to dismiss retained notification failures.", {
exact: true,
})
.waitFor();
await page.evaluate(() => {
window.action = "accept";
window.health = "error";
});
await retry.click();
await healthCard
.getByRole("button", { name: "Refresh delivery status", exact: true })
.waitFor();
assert.equal(await feedback.getByRole("button").count(), 0);
assert.equal(
await page
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.count(),
0,
);
// Recover the unavailable snapshot; an accepted mutation did not clear it.
await page.evaluate(() => {
window.health = "degraded";
window.action = "reject";
});
await healthCard
.getByRole("button", { name: "Refresh delivery status", exact: true })
.click();
await dismiss.click();
await feedback
.getByText("Unable to dismiss retained notification failures.", {
exact: true,
})
.waitFor();
if (surface === "destinations") {
await page.evaluate(() => {
window.health = "healthy";
});
await healthCard
.getByRole("button", {
name: "Refresh delivery status",
exact: true,
})
.click();
await healthCard.waitFor({ state: "detached" });
assert.match(await feedback.innerText(), /Unable to dismiss/);
}
assert.equal(
await feedback.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
return [...range.getClientRects()].every(
(r) => r.left >= 0 && r.right <= innerWidth,
);
}),
true,
"feedback text and controls fit viewport",
);
await page.mouse.move(0, 0);
await page.clock.fastForward(12000);
await page.clock.runFor(400);
assert.match(await feedback.innerText(), /Unable to dismiss/);
await page.screenshot({
path: resolve(output, width + "-" + surface + "-" + theme + ".png"),
fullPage: true,
});
const clear = feedback.getByRole("button", {
name: "Clear recovery message",
});
await clear.focus();
await page.keyboard.press("Enter");
assert.equal(await feedback.getByRole("button").count(), 0);
assert.equal(
await feedback.evaluate((el) => document.activeElement === el),
true,
);
assert.deepEqual(errors, []);
await page.close();
cases++;
}
console.log(
JSON.stringify({
result: "passed",
cases,
viewports: [1440, 900, 390],
scope:
"Real OverviewTab and DestinationsTab, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt.",
}),
);
} finally {
await browser?.close();
await server.close();
}