diff --git a/docs/qualification/recovery-feedback/1440-destinations-dark.png b/docs/qualification/recovery-feedback/1440-destinations-dark.png new file mode 100644 index 000000000..0501a06ee Binary files /dev/null and b/docs/qualification/recovery-feedback/1440-destinations-dark.png differ diff --git a/docs/qualification/recovery-feedback/1440-destinations-light.png b/docs/qualification/recovery-feedback/1440-destinations-light.png new file mode 100644 index 000000000..0bcaf2e48 Binary files /dev/null and b/docs/qualification/recovery-feedback/1440-destinations-light.png differ diff --git a/docs/qualification/recovery-feedback/1440-overview-dark.png b/docs/qualification/recovery-feedback/1440-overview-dark.png new file mode 100644 index 000000000..2d12ba7da Binary files /dev/null and b/docs/qualification/recovery-feedback/1440-overview-dark.png differ diff --git a/docs/qualification/recovery-feedback/1440-overview-light.png b/docs/qualification/recovery-feedback/1440-overview-light.png new file mode 100644 index 000000000..63a7d7aba Binary files /dev/null and b/docs/qualification/recovery-feedback/1440-overview-light.png differ diff --git a/docs/qualification/recovery-feedback/390-destinations-dark.png b/docs/qualification/recovery-feedback/390-destinations-dark.png new file mode 100644 index 000000000..14e7b6331 Binary files /dev/null and b/docs/qualification/recovery-feedback/390-destinations-dark.png differ diff --git a/docs/qualification/recovery-feedback/390-destinations-light.png b/docs/qualification/recovery-feedback/390-destinations-light.png new file mode 100644 index 000000000..41af585e6 Binary files /dev/null and b/docs/qualification/recovery-feedback/390-destinations-light.png differ diff --git a/docs/qualification/recovery-feedback/390-overview-dark.png b/docs/qualification/recovery-feedback/390-overview-dark.png new file mode 100644 index 000000000..81846381e Binary files /dev/null and b/docs/qualification/recovery-feedback/390-overview-dark.png differ diff --git a/docs/qualification/recovery-feedback/390-overview-light.png b/docs/qualification/recovery-feedback/390-overview-light.png new file mode 100644 index 000000000..ad7068fc3 Binary files /dev/null and b/docs/qualification/recovery-feedback/390-overview-light.png differ diff --git a/docs/qualification/recovery-feedback/900-destinations-dark.png b/docs/qualification/recovery-feedback/900-destinations-dark.png new file mode 100644 index 000000000..4fa63ee56 Binary files /dev/null and b/docs/qualification/recovery-feedback/900-destinations-dark.png differ diff --git a/docs/qualification/recovery-feedback/900-destinations-light.png b/docs/qualification/recovery-feedback/900-destinations-light.png new file mode 100644 index 000000000..241c2699b Binary files /dev/null and b/docs/qualification/recovery-feedback/900-destinations-light.png differ diff --git a/docs/qualification/recovery-feedback/900-overview-dark.png b/docs/qualification/recovery-feedback/900-overview-dark.png new file mode 100644 index 000000000..6db3ed7cd Binary files /dev/null and b/docs/qualification/recovery-feedback/900-overview-dark.png differ diff --git a/docs/qualification/recovery-feedback/900-overview-light.png b/docs/qualification/recovery-feedback/900-overview-light.png new file mode 100644 index 000000000..64bf06dfd Binary files /dev/null and b/docs/qualification/recovery-feedback/900-overview-light.png differ diff --git a/docs/qualification/recovery-feedback/README.md b/docs/qualification/recovery-feedback/README.md new file mode 100644 index 000000000..bc6d9490b --- /dev/null +++ b/docs/qualification/recovery-feedback/README.md @@ -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. diff --git a/docs/qualification/recovery-feedback/browser-result.json b/docs/qualification/recovery-feedback/browser-result.json new file mode 100644 index 000000000..303655a2a --- /dev/null +++ b/docs/qualification/recovery-feedback/browser-result.json @@ -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." +} diff --git a/docs/qualification/recovery-feedback/failed.png b/docs/qualification/recovery-feedback/failed.png new file mode 100644 index 000000000..a33cd16fd Binary files /dev/null and b/docs/qualification/recovery-feedback/failed.png differ diff --git a/docs/qualification/recovery-feedback/source-sha256.json b/docs/qualification/recovery-feedback/source-sha256.json new file mode 100644 index 000000000..6bf27433b --- /dev/null +++ b/docs/qualification/recovery-feedback/source-sha256.json @@ -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" +} diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 297254d6b..27fd5c156 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0e745279f..9b9875cc7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,24 @@ ## 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. + The shared action evidence disclosure preserves the named observer independently of the executor. Its observation timestamp uses the neutral label `Observed`, followed by the separate Pulse receipt time. Independent Proxmox API evidence diff --git a/frontend-modern/src/components/Toast/__tests__/Toast.test.tsx b/frontend-modern/src/components/Toast/__tests__/Toast.test.tsx index 047da5a10..8187601f2 100644 --- a/frontend-modern/src/components/Toast/__tests__/Toast.test.tsx +++ b/frontend-modern/src/components/Toast/__tests__/Toast.test.tsx @@ -11,6 +11,32 @@ describe('Toast', () => { vi.useRealTimers(); }); + it.each([ + ['error', 'alert', 'assertive'], + ['warning', 'alert', 'assertive'], + ['success', 'status', 'polite'], + ['info', 'status', 'polite'], + ] as const)('preserves %s announcement semantics without moving focus', (type, role, live) => { + render(() => ( + <> + + + + )); + const action = screen.getByRole('button', { name: 'Recovery action' }); + action.focus(); + + window.showToast(type, 'Recovery result', 'Action context'); + + const announcement = screen.getByRole(role); + expect(announcement).toHaveAttribute('aria-live', live); + expect(announcement).toHaveAttribute('aria-atomic', 'true'); + expect(announcement).toHaveTextContent('Recovery result'); + expect(announcement).toHaveTextContent('Action context'); + expect(action).toHaveFocus(); + expect(screen.queryByRole(role === 'alert' ? 'status' : 'alert')).not.toBeInTheDocument(); + }); + it('keeps all toasts created within a batch', () => { render(() => ); diff --git a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx index 099a00675..3c2fdd021 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx @@ -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(null); + render(() => 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(); + }); +}); diff --git a/frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx b/frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx new file mode 100644 index 000000000..c356f4081 --- /dev/null +++ b/frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx @@ -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 ( +
+
+ + +
+

+ {props.message} +

+ +
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/features/alerts/OverviewTab.tsx b/frontend-modern/src/features/alerts/OverviewTab.tsx index de8319f39..64daf8a1b 100644 --- a/frontend-modern/src/features/alerts/OverviewTab.tsx +++ b/frontend-modern/src/features/alerts/OverviewTab.tsx @@ -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 (
+ ({ }, })); +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn() }, +})); + const healthWith = (status: string) => ({ queue: { status, failed: 3, deadLetter: 1 } }) as never; describe('useNotificationDeliveryHealth', () => { beforeEach(() => { + vi.mocked(notificationStore.success).mockReset(); + vi.mocked(notificationStore.error).mockReset(); vi.mocked(NotificationsAPI.getHealth).mockReset(); vi.mocked(NotificationsAPI.dismissTerminalFailures).mockReset(); vi.mocked(NotificationsAPI.retryTerminalFailures).mockReset(); @@ -200,6 +207,13 @@ describe('useNotificationDeliveryHealth', () => { expect(NotificationsAPI[action]).toHaveBeenCalledOnce(); expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(3); expect(onAfterQueueAction).toHaveBeenCalledOnce(); + // A failed health read must not reclassify an accepted queue mutation. + expect(notificationStore.success).toHaveBeenCalledWith( + action === 'retryTerminalFailures' + ? '2 retained deliveries queued for retry.' + : '2 retained failures dismissed.', + ); + expect(notificationStore.error).not.toHaveBeenCalled(); expect(state.deliveryHealth()).toBeNull(); expect(state.deliveryHealthUnavailable()).toBe(true); expect(state.deliveryNeedsAttention()).toBe(true); @@ -242,6 +256,15 @@ describe('useNotificationDeliveryHealth', () => { expect(NotificationsAPI[action]).toHaveBeenCalledTimes(outcome === 'rejected' ? 1 : 0); expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(1); expect(onAfterQueueAction).not.toHaveBeenCalled(); + expect(notificationStore.success).not.toHaveBeenCalled(); + expect(notificationStore.error).toHaveBeenCalledTimes(outcome === 'rejected' ? 1 : 0); + if (outcome === 'rejected') { + expect(notificationStore.error).toHaveBeenCalledWith( + action === 'retryTerminalFailures' + ? 'Unable to retry retained notification deliveries.' + : 'Unable to dismiss retained notification failures.', + ); + } expect(state.deliveryHealth()).toBe(health); expect(state.deliveryNeedsAttention()).toBe(true); expect(state.deliveryHealthUnavailable()).toBe(false); @@ -253,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(); + } + })); }); diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx index ffbe6110b..3bc361396 100644 --- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx @@ -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) { )} + (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, diff --git a/scripts/check-recovery-feedback.mjs b/scripts/check-recovery-feedback.mjs new file mode 100644 index 000000000..809c80269 --- /dev/null +++ b/scripts/check-recovery-feedback.mjs @@ -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 false} dismissQuickTip={noop} showAcknowledged={()=>true} +setShowAcknowledged={noop} alertsDisabled={()=>false}/>;} +function Destinations() {return ({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
{location.pathname.endsWith('destinations')?:}
;} +render(()=>,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( + '
', + ); + } 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(); +}