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: {