From a414f588f6d1b656636d89d248446ad9fc37c31b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:28:12 +0100 Subject: [PATCH] Withdraw failed backup protection assessments A failed posture poll retained the previous green rows, health counts and verification explanation beside an unavailable notice. Withdraw all retained assessments and policy after any batch fails, preserving independently readable restore points and the last accepted result only while a refresh is pending. Add a keyboard-operable, pending-disabled posture-only retry. Cover failed batches, cache remounts and consuming failure/recovery transitions, with desktop and narrow production-browser proof. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 11 ++ .../internal/subsystems/storage-recovery.md | 13 +++ .../5089f81512e1dadf.json | 44 ++++++++ .../features/proxmox/ProxmoxBackupsTable.tsx | 20 +++- .../__tests__/ProxmoxBackupsTable.test.tsx | 64 +++++++++++ .../__tests__/useProtectionPostures.test.ts | 104 +++++++++++++++++- .../src/hooks/useProtectionPostures.ts | 9 +- 7 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-verification/5089f81512e1dadf.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 96a1fcf474..24ae5091c0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,17 @@ ## Purpose +### Backup posture failure and retry stay source-local (24 October 2026) + +The Backups posture notice offers a keyboard-operable retry with the existing +inventory-notice styling, wrapping action and disabled pending state. It +refreshes only posture, not independently readable PVE/PBS inventories. Rows, +health counts and expanded explanations withdraw failed retained assessments +until a complete successful response arrives; independently observed restore +points remain usable. Consuming table tests cover the notice and projection +together. This changes no shared query cache, route, server policy or execution +authority. + ### SSO editor feedback and exact group names (24 October 2026) The provider editor scrolls its fields independently of its header and recovery diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bc0fcaf345..d1aac6f2e9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,19 @@ ## Purpose +### Failed posture refreshes withdraw retained assessments (24 October 2026) + +Protection posture remains visible while a refresh is pending, but a settled +failure in any bounded batch withdraws all retained assessments and their +policy from the active Coverage projection. Rows and health counts become +Unknown; old protection explanations and provider-quality claims disappear. +Independently readable backup artifacts remain available. A source-local, +disabled-while-pending retry refreshes posture only, and restores assessments +only after the complete joined read succeeds. Failed cached results cannot +resurrect assessments on remount. The hook and consuming Backups table tests +cover these transitions without changing server policy, access scope, backup +execution or native guest recovery. + ### Patrol timer checkpoints preserve run and recovery history The existing Patrol history envelope retains the next scheduled deadline and diff --git a/frontend-modern/browser-verification/5089f81512e1dadf.json b/frontend-modern/browser-verification/5089f81512e1dadf.json new file mode 100644 index 0000000000..a9ca91574b --- /dev/null +++ b/frontend-modern/browser-verification/5089f81512e1dadf.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "9182e7b206f889d29688f41ee2c4a484b21d348f", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/hooks/useProtectionPostures.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "63344ef1136ea314f80c846f5855a2fa5401d527c137f1d0a7c8ac21528a1802", + "frontend-modern/src/hooks/useProtectionPostures.ts": "3a99f8501848ba7ecf076708f77b8b42e557d556e7eb7dd232cbfd2851a4e8eb" + }, + "routes": [ + "/proxmox/backups/coverage" + ], + "states": [ + "Accepted protected posture with provider evidence and an independent PVE restore point", + "Settled HTTP503 posture poll: Unknown rows and counts, no retained policy/explanation/provider claims, restore point still visible", + "Pending posture-only retry: disabled button and Unknown retained", + "Successful retry: current attention assessment, explanation and policy restored" + ], + "interactions": [ + "Expand actual Coverage details by mouse at1440px and row tap at320px", + "Advance the browser clock through the existing30second posture poll and return synthetic HTTP503", + "Focus retry and press Enter; hold response to inspect disabled pending state, then release a new assessment", + "Check that PVE/PBS inventory requests remain one each, posture requests are three, no horizontal overflow or page errors" + ], + "viewports": [ + { + "width": 1440, + "height": 1000 + }, + { + "width": 320, + "height": 1000 + } + ], + "verified_at": "2026-10-24T04:27:45Z", + "browsers": [ + "Chromium via Playwright1.56.1", + "WebKit via Playwright1.56.1" + ], + "evidence": "Lane RECEIPTS.md links raw browser results, scripts and nine natively inspected captures. Full production app with synthetic local read-only HTTP fixtures; not native sign-in, guest recovery or original hosted-run replay." +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index 1020b8ddd8..8a96a8247f 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -726,9 +726,23 @@ export const ProxmoxBackupsTable: Component<{
-
- Protection posture is unavailable. Pulse is showing restore evidence without - guessing whether workloads are protected. +
+ + Protection posture is unavailable. Pulse is showing restore evidence without + guessing whether workloads are protected. + +
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index bfe73865b9..5e7b37af71 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -22,6 +22,7 @@ import { } from '@/features/platformPage/sharedPlatformPage'; import { TABLE_CARD_FRAME_CLASS } from '@/components/shared/TableCard'; import type { Resource } from '@/types/resource'; +import type { ProtectionPosturesResponse } from '@/types/recovery'; import { getRecoveryFullDateLabel } from '@/utils/recoveryDatePresentation'; import { eventBus } from '@/stores/events'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; @@ -1545,3 +1546,66 @@ describe('Coverage qualifies missing restore evidence through the consuming API expect(detail).not.toHaveTextContent('No restore points'); }); }); + +describe('Coverage withdraws failed protection assessments', () => { + it('replaces retained healthy rows, counts and explanations with Unknown, then retries only posture', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + mockBackupAPIs(); + window.history.replaceState({}, '', '/proxmox/backups/coverage'); + renderInRouter(() => ( + } workloads={[workloadResource]} /> + )); + const row = () => + document.querySelector('[data-proxmox-backup-row="coverage"]') as HTMLElement; + await waitFor(() => expect(row()).toHaveTextContent('Protected')); + const accepted = (await apiFetchJSONMock.mock.results[0].value) as ProtectionPosturesResponse; + expect(screen.getByRole('button', { name: '1 protected' })).toBeInTheDocument(); + fireEvent.click(within(row()).getByRole('button', { name: /expand details/i })); + const detail = await screen.findByTestId('proxmox-coverage-detail'); + expect(detail).toHaveTextContent('A current verified backup is available.'); + + apiFetchJSONMock.mockRejectedValue(new Error('Synthetic posture refresh failed')); + await vi.advanceTimersByTimeAsync(30_000); + await screen.findByText(/Protection posture is unavailable/); + expect(row()).toHaveTextContent('Unknown'); + expect(row()).not.toHaveTextContent('Protected'); + expect(screen.getByText('protected', { exact: true }).closest('li')).toHaveTextContent( + /0\s*protected/, + ); + expect(screen.getByRole('button', { name: '1 unknown' })).toBeInTheDocument(); + expect(detail).not.toHaveTextContent('A current verified backup is available.'); + expect(detail).not.toHaveTextContent('History Complete'); + expect(detail).toHaveTextContent('Restore evidence'); + expect(within(detail).getByText('PVE file', { exact: true })).toBeInTheDocument(); + expect(document.querySelector('[data-coverage-strip-footnote]')).toBeNull(); + + const pending = deferred(); + apiFetchJSONMock.mockReturnValue(pending.promise); + const retry = screen.getByRole('button', { name: 'Retry protection posture' }); + fireEvent.click(retry); + expect(retry).toBeDisabled(); + expect(retry).toHaveTextContent('Retrying'); + expect(row()).toHaveTextContent('Unknown'); + fireEvent.click(retry); + expect(apiFetchJSONMock).toHaveBeenCalledTimes(3); + pending.resolve({ + ...accepted, + data: accepted.data.map((posture) => ({ + ...posture, + state: 'attention', + explanation: 'The current backup verification needs attention.', + })), + }); + await waitFor(() => expect(row()).toHaveTextContent(/attention/i)); + expect(detail).toHaveTextContent('The current backup verification needs attention.'); + expect(screen.queryByText(/Protection posture is unavailable/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '1 attention' })).toBeInTheDocument(); + expect(document.querySelector('[data-coverage-strip-footnote]')).not.toBeNull(); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pve')).toHaveLength(1); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/frontend-modern/src/hooks/__tests__/useProtectionPostures.test.ts b/frontend-modern/src/hooks/__tests__/useProtectionPostures.test.ts index 54445ea790..4d53792f7f 100644 --- a/frontend-modern/src/hooks/__tests__/useProtectionPostures.test.ts +++ b/frontend-modern/src/hooks/__tests__/useProtectionPostures.test.ts @@ -7,7 +7,7 @@ import { normalizeProtectionPostureResourceIDs, useProtectionPostures, } from '@/hooks/useProtectionPostures'; -import type { ProtectionPosturesResponse } from '@/types/recovery'; +import type { ProtectionPosturesResponse, ProtectionState } from '@/types/recovery'; const apiFetchJSONMock = vi.hoisted(() => vi.fn()); vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: apiFetchJSONMock })); @@ -97,4 +97,106 @@ describe('useProtectionPostures transport', () => { // count or limit would only be invented client-side with no reader. expectTypeOf().toEqualTypeOf<'data' | 'policy'>(); }); + + it.each(['protected', 'attention', 'unprotected', 'unknown'])( + 'withdraws a retained %s assessment if any batch fails, until a complete retry succeeds', + async (state) => { + const resourceIDs = Array.from({ length: 201 }, (_, index) => `vm:${index}`); + const response = (url: string): ProtectionPosturesResponse => ({ + data: new URL(url, 'https://pulse.invalid').searchParams + .getAll('resourceId') + .map((subjectResourceId) => ({ + subjectResourceId, + state, + freshness: 'current', + verification: 'verified', + coverage: 'complete', + providerStates: [], + repositoryResourceIds: [], + evidenceIds: [], + explanation: 'Previous assessment', + evaluatedAt: '2026-10-24T04:15:12Z', + })), + policy: { + freshnessWindowSeconds: 604800, + verificationWindowSeconds: 3600, + requireVerification: true, + }, + }); + apiFetchJSONMock.mockImplementation(async (url: string) => response(url)); + let dispose = () => {}; + const hook = createRoot((rootDispose) => { + dispose = rootDispose; + return useProtectionPostures(() => resourceIDs); + }); + try { + await vi.waitFor(() => expect(hook.postures()).toHaveLength(201)); + const failure = new Error('Second batch failed'); + apiFetchJSONMock.mockImplementation(async (url: string) => { + if (new URL(url, 'https://pulse.invalid').searchParams.getAll('resourceId').length === 1) + throw failure; + return response(url); + }); + const failedRead = hook.refetch(); + expect(hook.postures()).toHaveLength(201); + await failedRead; + expect(hook.response.error).toBe(failure); + expect(hook.resolvedOnce()).toBe(true); + expect(hook.postures()).toEqual([]); + expect(hook.postureByResourceID().size).toBe(0); + expect(hook.policy().freshnessWindowSeconds).toBe(0); + + const pending: (() => void)[] = []; + apiFetchJSONMock.mockImplementation( + (url: string) => + new Promise((resolve) => { + pending.push(() => resolve(response(url))); + }), + ); + const retry = hook.refetch(); + expect(hook.response.loading).toBe(true); + expect(hook.postures()).toEqual([]); + expect(pending).toHaveLength(2); + pending[0](); + await Promise.resolve(); + expect(hook.postures()).toEqual([]); + pending[1](); + await retry; + expect(hook.response.error).toBeNull(); + expect(hook.postures()).toHaveLength(201); + expect(hook.policy().freshnessWindowSeconds).toBe(604800); + } finally { + dispose(); + } + }, + ); + + it('does not restore a failed cached assessment when Coverage remounts', async () => { + apiFetchJSONMock.mockResolvedValue({ + data: [{ subjectResourceId: 'vm:1', state: 'protected' }], + policy: { freshnessWindowSeconds: 604800 }, + }); + let dispose = () => {}; + const mount = () => + createRoot((rootDispose) => { + dispose = rootDispose; + return useProtectionPostures(() => ['vm:1']); + }); + const original = mount(); + await vi.waitFor(() => expect(original.postures()).toHaveLength(1)); + apiFetchJSONMock.mockRejectedValue(new Error('Refresh failed')); + await original.refetch(); + dispose(); + + apiFetchJSONMock.mockImplementation(() => new Promise(() => {})); + const remounted = mount(); + try { + expect(remounted.response.error).toBeTruthy(); + expect(remounted.postures()).toEqual([]); + expect(remounted.postureByResourceID().size).toBe(0); + expect(remounted.policy().freshnessWindowSeconds).toBe(0); + } finally { + dispose(); + } + }); }); diff --git a/frontend-modern/src/hooks/useProtectionPostures.ts b/frontend-modern/src/hooks/useProtectionPostures.ts index c4e83f3315..eab269d6e4 100644 --- a/frontend-modern/src/hooks/useProtectionPostures.ts +++ b/frontend-modern/src/hooks/useProtectionPostures.ts @@ -83,11 +83,16 @@ export function useProtectionPostures(resourceIDs: Accessor(() => state.value().data ?? []); + // A retained assessment is useful while a refresh is pending, not after + // that read fails. Withdraw the entire joined result (including its policy) + // so rows, health counts and explanations cannot still claim protection + // beside the unavailable notice. A successful full retry restores it. + const acceptedResponse = createMemo(() => (state.error() ? EMPTY_RESPONSE : state.value())); + const postures = createMemo(() => acceptedResponse().data ?? []); const postureByResourceID = createMemo>( () => new Map(postures().map((posture) => [posture.subjectResourceId, posture] as const)), ); - const policy = createMemo(() => state.value().policy ?? EMPTY_POLICY); + const policy = createMemo(() => acceptedResponse().policy ?? EMPTY_POLICY); return { response: {