diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index dd225b090f..0bf42b1a70 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -88,6 +88,17 @@ API context; late responses cannot repopulate old evidence. Disposal aborts reads and removes subscriptions. This uses the existing query lifecycle, without new inventory polling or a remount cache. +Successful HTTP responses still need a readable backup envelope, collections +and consumed row fields. Invalid JSON, missing data and malformed rows produce +a source-local unavailable state with fixed format guidance, never raw response +content, a fabricated empty inventory or a page-level rendering failure that +hides another source. No invalid rows are silently dropped, no completion flags +are coerced, and only a validated response clears the warning. Explicitly nil +PVE collections remain compatible with the existing Go encoding. +`proxmoxBackupInventory.test.ts`, mounted backup-table controls and +`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated +keyboard/touch recovery, not native backup or released availability. + Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending, partial/both-source failure, isolated retry, fulfilled empty counts, 401/403, organisation replacement, late response and disposal. Production component, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0d7f50f36c..3711fff084 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -41,6 +41,17 @@ disposal. `browser-tests/backup-inventory.cjs` uses production components, API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses; these controls are not native backup, permission, thaw or release acceptance. +An HTTP success alone does not establish readable recovery evidence. The client +validates the data envelope, source collections and consumed row fields before +publishing that inventory. Malformed or undecodable responses invalidate that +source atomically, not a silently truncated list or confirmed empty inventory. +PVE's explicitly nil Go collections remain valid empty observations; absent +fields are not nil collections. Optional unknown facts and additional fields +retain their existing meaning, and completion flags are never coerced. +`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and +`browser-tests/backup-inventory-format.cjs` cover this response boundary, source +isolation and recovery without extending native protection or thaw claims. + ### Backup identity, phone storage and History consent composition The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 19287752f7..98e6481a30 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -35,6 +35,13 @@ count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows and source notices on desktop/phone. This changes no resource identity, native provider assessment or permission policy. +The backup inventory count also remains unavailable when an HTTP200 response +fails the client backup-envelope/row validation; transport success is not an +observed zero. A malformed source cannot remove independently readable canonical +server health or the other source's artifacts. Fixed diagnostics expose no +response body. The inventory parser, mounted table and +`browser-tests/backup-inventory-format.cjs` exercise that combined boundary. + ### Guest disk freshness presentation Existing workload disk evidence retains its source reason through the row, diff --git a/frontend-modern/browser-tests/backup-inventory-format.cjs b/frontend-modern/browser-tests/backup-inventory-format.cjs new file mode 100644 index 0000000000..1d0fce2be9 --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory-format.cjs @@ -0,0 +1,278 @@ +// Production backup readers/table/router/CSS, synthetic HTTP only. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const phase = 'final'; +const root = '/workspace/frontend-modern'; +const output = `/workspace/tmp/backup-format-${phase}`; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } }; +const emptyPBS = { data: { backups: [] } }; +const archive = { + id: 'archive-112', + instance: 'pve-a', + node: 'pve-a', + storage: 'local', + type: 'ct', + vmid: 112, + time: '2026-10-03T01:00:00Z', + format: 'zst', + volid: 'local:backup/vzdump-lxc-112.tar.zst', +}; +const snapshot = { + id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z', + instance: 'pbs-main', + datastore: 'main', + backupType: 'ct', + vmid: '112', + backupTime: '2026-10-03T02:00:00Z', + files: ['index.json.blob'], +}; +const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } }; +const pbsPayload = { data: { backups: [snapshot] } }; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5298, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }; + const page = await browser.newPage({ + viewport, + isMobile: phone, + hasTouch: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + screenshots = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + const sources = { pve: emptyPVE, pbs: {} }; + let retryGate; + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5298') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: request.method() }); + const key = + url.pathname === '/api/backups/pbs' + ? 'pbs' + : url.pathname === '/api/backups/pve' + ? 'pve' + : null; + const payload = key ? sources[key] : { data: [], policy: {}, meta: {} }; + if (key === 'pbs' && retryGate) await retryGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + }); + const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]'); + const notice = (source) => + page.getByRole('status').filter({ + hasText: `${source} backup inventory is unavailable`, + }); + const load = async () => { + await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', { + waitUntil: 'domcontentloaded', + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + }; + const capture = async (suffix) => { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const file = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: file, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) }); + }; + + await load(); + if (phase === 'baseline') { + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await notice('PBS').count(), 0); + checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet'); + } else { + await notice('PBS').waitFor(); + await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor(); + assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0); + await page.getByText(/The response format is invalid/).waitFor(); + if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor(); + checks.push( + 'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero', + ); + } + await capture('missing-data'); + + sources.pve = pvePayload; + sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary', + ); + } else { + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + assert.match(await rows().first().innerText(), /PVE file/); + assert.equal(await page.getByRole('alert').count(), 0); + assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0); + checks.push( + 'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed', + ); + } + await capture('pbs-invalid'); + + sources.pbs = pbsPayload; + sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary', + ); + } else { + await notice('PVE').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + await rows().first().getByText('PBS', { exact: true }).waitFor(); + await rows().first().getByText('Snapshot', { exact: true }).waitFor(); + assert.equal(await page.getByRole('alert').count(), 0); + checks.push('Malformed PVE is source-local and independent PBS evidence remains usable'); + } + await capture('pve-invalid'); + + if (phase === 'final') { + sources.pve = pvePayload; + sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } }; + await load(); + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal( + await rows().count(), + 1, + 'a mistyped completion flag must not become recovery evidence', + ); + const before = requests.filter((r) => r.path === '/api/backups/pve').length; + sources.pbs = pbsPayload; + let release; + retryGate = new Promise((resolve) => (release = resolve)); + const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true }); + if (phone) await retry.tap(); + else { + await retry.focus(); + await page.keyboard.press('Enter'); + } + assert.equal(await retry.isDisabled(), true); + await notice('PBS').waitFor(); + assert.equal(await rows().count(), 1); + release(); + await page.waitForFunction( + () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2, + ); + retryGate = null; + assert.equal(await notice('PBS').count(), 0); + assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before); + await capture('recovered'); + checks.push( + 'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery', + ); + + sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } }; + sources.pbs = emptyPBS; + await load(); + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0); + checks.push('Explicit Go nil PVE collections remain a valid empty observation'); + } + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + 1, + ); + assert.equal(overflow, false); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert(requests.every((r) => r.method === 'GET')); + observations.push({ + name, + browserVersion: browser.version(), + viewport, + checks, + requests, + errors, + offOrigin, + overflow, + screenshots, + }); + await browser.close(); + browser = null; + } + const runtime = [ + 'src/features/proxmox/ProxmoxBackupsTable.tsx', + 'src/features/proxmox/proxmoxBackupInventory.ts', + ].filter((p) => fs.existsSync(path.join(root, p))); + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + phase, + playwrightVersion, + runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])), + observations, + }, + null, + 2, + ) + '\n', + ); + console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification/78c9d8427bdfa307.json b/frontend-modern/browser-verification/78c9d8427bdfa307.json new file mode 100644 index 0000000000..099102e7a1 --- /dev/null +++ b/frontend-modern/browser-verification/78c9d8427bdfa307.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "base_sha": "f5925942f49b088689695678b98e703af9ead9ef", + "verified_at": "2026-10-03T21:03:10.375831Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095" + }, + "routes": [ + "/browser-tests/backup-inventory.html?view=date" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "HTTP200 missing PBS envelope stays incomplete, never no-backups/zero", + "Malformed PBS/PVE collections preserve independent source and server health without a rendering failure", + "Mistyped incomplete flags are unavailable, not completed restore points", + "Explicit Go nil PVE collections remain valid empty observations" + ], + "interactions": [ + "Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success", + "Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium-desktop", + "version": "141.0.7390.37" + }, + { + "engine": "webkit-phone", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "limitations": [ + "Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.", + "Production/embed build is separately source-proved; no new route, polling, source cache or authority." + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index e3d51a93b9..c0bf4513ea 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -29,9 +29,7 @@ import type { GuestSnapshot, PBSBackup, PBSBackupsPayload, - PBSBackupsResponse, PVEBackupsPayload, - PVEBackupsResponse, StorageBackup, } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -70,6 +68,12 @@ import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip'; import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable'; import { ProxmoxCoverageTable } from './ProxmoxCoverageTable'; import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable'; +import { + BackupInventoryFormatError, + parsePBSBackupInventory, + parsePVEBackupInventory, + readBackupInventoryJSON, +} from './proxmoxBackupInventory'; // One backups surface, two operator views: a chronological recoverable-artifact // feed for "what ran when", and a guest coverage table for "what is protected". @@ -97,8 +101,7 @@ async function fetchPVEBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PVEBackupsResponse; - return payload?.data ?? EMPTY_PVE_BACKUPS; + return parsePVEBackupInventory(await readBackupInventoryJSON(response)); } async function fetchPBSBackups(signal: AbortSignal): Promise { @@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PBSBackupsResponse; - return payload?.data ?? EMPTY_PBS_BACKUPS; + return parsePBSBackupInventory(await readBackupInventoryJSON(response)); } export const ProxmoxBackupsTable: Component<{ @@ -702,6 +704,11 @@ export const ProxmoxBackupsTable: Component<{ {label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts are incomplete. {getAPIReadAccessErrorMessage(query.error())} + + {' '} + The response format is invalid. Missing restore points do not mean no backups + exist. +