diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 936fdbcb10..4c20fd1e17 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,45 @@ ## Purpose +### Backup inventory ownership is source-scoped and unambiguous + +The existing Backups Coverage and By date views do not select a workload from +an arbitrary substring, display label or first matching row when VMIDs repeat. +PVE files, guest snapshots and task evidence use the supplied connection scope +before native node names or aliases. One VMID inside a matching PVE connection +stays associated after node migration; a known conflicting connection cannot be +rescued by a reused node name or a singleton fallback. With no connection scope, +an exact native node/alias must identify one candidate; wholly unscoped singleton +observations retain their existing compatibility behaviour. + +PBS instance and datastore identify a repository, not a PVE workload. A PBS +namespace disambiguates repeated VMIDs only with a unique exact native node, +alias or PVE connection label. Unique type/VMID PBS inventory retains the +existing root-namespace association. This bounded presentation fix does not +implement the runtime PBS source learner or alter server-owned protection policy. + +Unresolved artifacts remain listed once and keep complete, collision-safe source +keys (PVE connection/node or PBS instance/datastore/namespace), including host +backup labels. Equal VMIDs, repository labels or host names cannot pool them +across unproven sources. Untyped PVE tasks may join only PVE-scoped rows, not a +PBS-only unresolved row with a lookalike namespace. The existing collapsed group +and count say **unmatched**, not that the guest necessarily ceased to exist; +its explanation wraps on phones. These rows remain **not evaluated**, separate +from canonical provider-owned protection posture and live workload counts. + +Verification: `proxmoxBackupRecoveryModel.identity.test.ts` reproduces prefix +and reused-node collisions, order independence, migration/alias controls, +conflicting scope, ambiguous type/namespace and separated unresolved sources. +Mounted `ProxmoxBackupsTable.test.tsx` backup-identity cases cover both views, +artifact preservation, reorder/display rename, expansion identity and no extra +inventory read. `browser-tests/backup-identity.cjs` uses production tables, +router, inventory readers and CSS with synthetic responses on desktop Chromium +and touch WebKit at 390px/320px, including keyboard/touch disclosure, ambiguity, +readable unmatched explanation and same-resource recovery. No native backup +identity, restore, guest thaw or containing-release acceptance follows from +these source/render observations. + + ### Diagnostics are live checks; downloads require a privacy review The existing Settings Diagnostics panel explains before either run action that diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 4da8d3d071..854284a70e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,45 @@ ## Purpose +### Backup inventory ownership is source-scoped and unambiguous + +The existing Backups Coverage and By date views do not select a workload from +an arbitrary substring, display label or first matching row when VMIDs repeat. +PVE files, guest snapshots and task evidence use the supplied connection scope +before native node names or aliases. One VMID inside a matching PVE connection +stays associated after node migration; a known conflicting connection cannot be +rescued by a reused node name or a singleton fallback. With no connection scope, +an exact native node/alias must identify one candidate; wholly unscoped singleton +observations retain their existing compatibility behaviour. + +PBS instance and datastore identify a repository, not a PVE workload. A PBS +namespace disambiguates repeated VMIDs only with a unique exact native node, +alias or PVE connection label. Unique type/VMID PBS inventory retains the +existing root-namespace association. This bounded presentation fix does not +implement the runtime PBS source learner or alter server-owned protection policy. + +Unresolved artifacts remain listed once and keep complete, collision-safe source +keys (PVE connection/node or PBS instance/datastore/namespace), including host +backup labels. Equal VMIDs, repository labels or host names cannot pool them +across unproven sources. Untyped PVE tasks may join only PVE-scoped rows, not a +PBS-only unresolved row with a lookalike namespace. The existing collapsed group +and count say **unmatched**, not that the guest necessarily ceased to exist; +its explanation wraps on phones. These rows remain **not evaluated**, separate +from canonical provider-owned protection posture and live workload counts. + +Verification: `proxmoxBackupRecoveryModel.identity.test.ts` reproduces prefix +and reused-node collisions, order independence, migration/alias controls, +conflicting scope, ambiguous type/namespace and separated unresolved sources. +Mounted `ProxmoxBackupsTable.test.tsx` backup-identity cases cover both views, +artifact preservation, reorder/display rename, expansion identity and no extra +inventory read. `browser-tests/backup-identity.cjs` uses production tables, +router, inventory readers and CSS with synthetic responses on desktop Chromium +and touch WebKit at 390px/320px, including keyboard/touch disclosure, ambiguity, +readable unmatched explanation and same-resource recovery. No native backup +identity, restore, guest thaw or containing-release acceptance follows from +these source/render observations. + + ### PBS datastore capacity is not inferred from failed-read zeros The existing Backups server table distinguishes a named datastore whose status diff --git a/frontend-modern/browser-tests/backup-identity.cjs b/frontend-modern/browser-tests/backup-identity.cjs new file mode 100644 index 0000000000..a0d37c4d5f --- /dev/null +++ b/frontend-modern/browser-tests/backup-identity.cjs @@ -0,0 +1,342 @@ +// Offline production-component proof; no live backup, restore or thaw claims. +// Bound preview build pools only; retain ordinary browser process isolation. +process.env.RAYON_NUM_THREADS = '1'; +process.env.GOMAXPROCS = '2'; +process.env.UV_THREADPOOL_SIZE = '2'; +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 root = '/workspace/frontend-modern'; +const output = '/workspace/frontend-modern/node_modules/backup-identity-browser'; +const origin = 'http://127.0.0.1:5324'; +const runtime = [ + 'frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts', + 'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx', +]; +const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const guest = (id, instance, node, extra = {}) => ({ + id, + name: id, + type: 'vm', + platformId: instance, + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: Date.parse('2026-10-04T12:00:00Z'), + proxmox: { vmid: 100, instance, nodeName: node, ...extra }, +}); +const east = guest('east-100', 'east', 'pve-1'); +const west = guest('west-100', 'west', 'pve-10'); +const payloads = { + '/api/backups/pve': { + data: { + storageBackups: [ + { + id: 'file-100', + instance: 'east', + node: 'pve-1', + type: 'vm', + vmid: 100, + storage: 'local', + time: '2026-10-04T11:00:00Z', + size: 1024, + format: 'zst', + protected: false, + }, + ], + guestSnapshots: [ + { + id: 'snapshot-100', + instance: 'east', + node: 'pve-1', + type: 'vm', + vmid: 100, + name: 'before-update', + time: '2026-10-04T11:30:00Z', + vmstate: false, + }, + ], + backupTasks: [ + { + id: 'task-100', + instance: 'east', + node: 'pve-1', + type: 'vzdump', + vmid: 100, + status: 'OK', + startTime: '2026-10-04T11:00:00Z', + endTime: '2026-10-04T11:05:00Z', + }, + ], + }, + }, + '/api/backups/pbs': { + data: { + backups: [ + { + id: 'pbs-100', + instance: 'pbs-server', + datastore: 'main', + namespace: 'pve-1', + backupType: 'vm', + vmid: '100', + backupTime: '2026-10-04T10:00:00Z', + files: ['index.json.blob'], + size: 2048, + protected: false, + verified: true, + }, + ], + }, + }, +}; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const version = require('playwright/package.json').version; + assert.equal( + version, + 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'), + optimizeDeps: { entries: ['browser-tests/backup-identity.html'] }, + server: { host: '127.0.0.1', port: 5324, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width] of [ + ['chromium-desktop', chromium, 1365], + ['webkit-phone', webkit, 390], + ['webkit-narrow', webkit, 320], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const phone = engine === webkit; + const viewport = { width, height: phone ? 844 : 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-04T12:00:00Z')); + const errors = [], + offOrigin = [], + requests = [], + checks = [], + screenshots = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== origin) { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: route.request().method() }); + // Independent provider-owned posture deliberately remains unchanged. + const payload = payloads[url.pathname] ?? { + data: ['east-100', 'west-100'].map((id) => ({ + subjectResourceId: id, + state: 'unprotected', + freshness: 'unknown', + verification: 'unknown', + coverage: 'none', + providerStates: [], + repositoryResourceIds: [], + evidenceIds: [], + explanation: 'Synthetic server-owned posture.', + evaluatedAt: '2026-10-04T12:00:00Z', + })), + policy: {}, + meta: { total: 2, totalPages: 1 }, + }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + }); + const rows = (kind) => page.locator(`[data-proxmox-backup-row="${kind}"]`); + const coverageRow = (name) => rows('coverage').filter({ hasText: name }); + const replace = (workloads) => + page.evaluate((next) => window.__replaceBackupIdentityWorkloads(next), workloads); + const capture = async (suffix) => { + await page.evaluate(() => { + for (const animation of document.getAnimations()) { + try { + animation.finish(); + } catch {} + } + }); + const file = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: file, fullPage: true, animations: 'disabled' }); + screenshots.push({ path: file, sha256: digest(file) }); + }; + await page.goto(`${origin}/browser-tests/backup-identity.html?view=coverage`, { + waitUntil: 'domcontentloaded', + timeout: 90000, + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await coverageRow('east-100').waitFor(); + // Navigate explicitly so the router owns the actual Coverage route. + await page.getByRole('link', { name: 'Coverage', exact: true }).click(); + await page.waitForURL('**/proxmox/backups/coverage'); + await coverageRow('east-100') + .getByText(phone ? 'Latest task: OK' : 'OK', { exact: true }) + .waitFor(); + assert(!(await coverageRow('west-100').textContent()).includes('OK')); + checks.push('prefix node names: task stays with exact PVE connection'); + const toggle = coverageRow('east-100').getByRole('button'); + await toggle.focus(); + await page.keyboard.press('Enter'); + assert.equal(await toggle.getAttribute('aria-expanded'), 'true'); + const detailID = await toggle.getAttribute('aria-controls'); + const detail = page.locator(`[id="${detailID}"]`); + await detail.getByText('PVE file', { exact: true }).waitFor(); + assert((await detail.textContent()).includes('PBS')); + assert((await detail.textContent()).includes('Snapshot')); + checks.push('keyboard opens all three correctly attributed artifact kinds'); + await replace([east, { ...west, proxmox: { ...west.proxmox, nodeDisplayName: 'pve-1' } }]); + assert.equal(await toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(await toggle.evaluate((element) => element === document.activeElement), true); + assert(!(await coverageRow('west-100').textContent()).includes('OK')); + checks.push( + 'reorder/display rename retains scoped evidence, expansion and focus without new reads', + ); + await capture('coverage-exact'); + await page.getByRole('link', { name: 'By date', exact: true }).click(); + await page.waitForURL('**/proxmox/backups/date'); + assert.equal(await rows('recoverable').count(), 3); + assert( + (await rows('recoverable').allTextContents()).every((row) => row.includes('east-100')), + ); + await replace([guest('west-100', 'west', 'pve-1'), east]); + await rows('recoverable').filter({ hasText: 'PBS' }).waitFor(); + const named = (await rows('recoverable').allTextContents()).filter((row) => + row.includes('east-100'), + ); + assert.equal(named.length, 2); + assert.equal(await rows('recoverable').count(), 3); + assert((await rows('recoverable').filter({ hasText: 'PBS' }).textContent()).includes('PBS')); + checks.push( + 'shared native node: ambiguous PBS is retained unnamed; scoped PVE file/snapshot remain named', + ); + await capture('date-ambiguous'); + await page.getByRole('link', { name: 'Coverage', exact: true }).click(); + await page.getByRole('button', { name: /1 unmatched backup/ }).click(); + await coverageRow('VM 100') + .getByText(phone ? 'N/A' : 'Not evaluated', { exact: true }) + .waitFor(); + await coverageRow('east-100') + .getByText(phone ? 'Latest task: OK' : 'OK', { exact: true }) + .waitFor(); + assert(!(await coverageRow('west-100').textContent()).includes('OK')); + if (phone) await coverageRow('VM 100').locator('td').first().tap(); + else await coverageRow('VM 100').getByRole('button').click(); + const unresolvedToggle = coverageRow('VM 100').getByRole('button'); + const unresolvedID = await unresolvedToggle.getAttribute('aria-controls'); + const unresolved = page.locator(`[id='${unresolvedID}']`); + await unresolved.getByText('PBS', { exact: true }).waitFor(); + assert(!(await unresolved.textContent()).includes('PVE file')); + checks.push( + 'unresolved Coverage evidence stays separate and not evaluated; server posture is independent', + ); + const noticeBounds = await page + .getByText('No unambiguous inventory match', { exact: true }) + .evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const rect = range.getBoundingClientRect(); + const owner = element.closest('button').getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + ownerLeft: owner.left, + ownerRight: owner.right, + }; + }); + assert( + noticeBounds.left >= noticeBounds.ownerLeft && + noticeBounds.right <= noticeBounds.ownerRight, + ); + checks.push('unmatched explanation wraps readably within the disclosure on phones'); + await capture('coverage-unmatched'); + // Return unique native placement: the same model retracts the orphan. + await replace([west, east]); + await page.getByRole('button', { name: /1 unmatched backup/ }).waitFor({ state: 'detached' }); + assert.equal(await rows('coverage').count(), 2); + checks.push( + 'unambiguous same-resource replacement restores attribution and removes orphan without an extra request', + ); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + 1, + ); + assert.equal(overflow, false); + assert.equal(requests.filter((request) => request.path === '/api/backups/pve').length, 1); + assert.equal(requests.filter((request) => request.path === '/api/backups/pbs').length, 1); + assert(requests.every((request) => request.method === 'GET')); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + observations.push({ + noticeBounds, + name, + browser: browser.version(), + viewport, + checks, + requests, + screenshots, + overflow, + errors, + offOrigin, + }); + fs.writeFileSync( + path.join(output, 'progress.json'), + JSON.stringify({ completedContexts: observations }, null, 2), + ); + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + result: 'passed', + playwright: version, + content_sha256: Object.fromEntries( + runtime.map((file) => [file, digest(`/workspace/${file}`)]), + ), + observations, + }, + null, + 2, + ), + ); + console.log( + 'Passed backup ownership in Chromium desktop and WebKit 390px/320px; synthetic production-component evidence.', + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/backup-identity.html b/frontend-modern/browser-tests/backup-identity.html new file mode 100644 index 0000000000..c4505c4324 --- /dev/null +++ b/frontend-modern/browser-tests/backup-identity.html @@ -0,0 +1,12 @@ + + + + + + Backup ownership verification + + +
+ + + diff --git a/frontend-modern/browser-tests/backup-identity.tsx b/frontend-modern/browser-tests/backup-identity.tsx new file mode 100644 index 0000000000..e147b7f8b2 --- /dev/null +++ b/frontend-modern/browser-tests/backup-identity.tsx @@ -0,0 +1,33 @@ +// Real router, inventory readers, production tables and CSS. Synthetic data. +import { createSignal, onCleanup, onMount } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Route, Router } from '@solidjs/router'; +import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable'; +import { identityGuest } from '../src/features/proxmox/__fixtures__/backupIdentity'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +function Fixture() { + const [workloads, setWorkloads] = createSignal([ + identityGuest('west-100', 'west', 'pve-10'), + identityGuest('east-100', 'east', 'pve-1'), + ]); + onMount(() => { + Object.assign(window, { __replaceBackupIdentityWorkloads: setWorkloads }); + onCleanup(() => Reflect.deleteProperty(window, '__replaceBackupIdentityWorkloads')); + }); + return ( +
+ } workloads={workloads()} /> +
+ ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/f1431d4a28ee8784.json b/frontend-modern/browser-verification/f1431d4a28ee8784.json new file mode 100644 index 0000000000..0c02297846 --- /dev/null +++ b/frontend-modern/browser-verification/f1431d4a28ee8784.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "c5e83ceb3a506aa00dd135bf53adb56c378c4273", + "content_sha256": { + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "fa1ed522fb020ca8a88b16a611fc55b52ad3960db4e328b83080f1827279003b", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "9cd562b566cfddafb622469bddd75a676e333b50bfac4f7abd7a3e5e181c5f40" + }, + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts" + ], + "routes": [ + "/proxmox/backups/coverage", + "/proxmox/backups/date" + ], + "states": [ + "Prefix/reused native node names with repeated VMIDs", + "Connection-owned PVE file/snapshot/task evidence", + "Ambiguous PBS namespace remains unmatched and not evaluated", + "Display rename/reorder and unique-placement replacement", + "Independent synthetic server protection posture" + ], + "interactions": [ + "Keyboard opens production Coverage evidence; expansion and focus survive reorder", + "Touch opens unmatched row; readable disclosure explanation at 390px and 320px", + "Route-backed By date/Coverage navigation", + "Ambiguous PBS owner withdrawal, artifact preservation and restored unique attribution without extra reads" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "verified_at": "2026-10-04T13:39:46Z", + "command": "pulse-worker-browser frontend-modern/browser-tests/backup-identity.cjs", + "playwright_version": "1.56.1", + "limitations": [ + "Synthetic production-component proof, not native backup identity, restore, thaw, installed acceptance or release qualification.", + "Existing compact table abbreviations/clipping are not addressed by this ownership fix." + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index 599e4f50c6..cc05949546 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -256,9 +256,9 @@ export const ProxmoxBackupsTable: Component<{ const [expandedCoverageRows, setExpandedCoverageRows] = createSignal>( new Set(), ); - // Orphaned backups (VM/LXC records whose guest no longer exists in inventory) + // Unmatched backups (VM/LXC evidence without an unambiguous live identity) // are collapsed by default so the main table is active backup targets, not a - // pile of nameless dead records sorted to the top. + // pile of unassigned artifacts sorted to the top. const [showOrphaned, setShowOrphaned] = createSignal(false); // Chronological feed sort and activity-chart controls. @@ -746,7 +746,7 @@ export const ProxmoxBackupsTable: Component<{ 0}> {' · '} - {orphanedTotalCount()} orphaned + {orphanedTotalCount()} unmatched } @@ -936,7 +936,7 @@ export const ProxmoxBackupsTable: Component<{
@@ -955,8 +957,8 @@ export const ProxmoxBackupsTable: Component<{ rows={orphanedCoverageRows()} hasAnyRows={orphanedTotalCount() > 0} emptyIcon={props.emptyIcon} - emptyTitle="No orphaned backups" - emptyDescription="Backups whose guest no longer exists will appear here." + emptyTitle="No unmatched backups" + emptyDescription="Backups without an unambiguous live guest match will appear here." sortKey={coverageSortKey} sortDirection={coverageSortDirection} onSort={handleCoverageSort} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index dc321ce203..fb62445289 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -4,6 +4,14 @@ import { createSignal, type JSX } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable'; +import { + BACKUP_IDENTITY_NOW, + identityGuest, + identityArchive, + identityPBS, + identitySnapshot, + identityTask, +} from '../__fixtures__/backupIdentity'; import { buildBackupServerRows } from '../ProxmoxBackupServersTable'; import proxmoxBackupServersTableSource from '../ProxmoxBackupServersTable.tsx?raw'; import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw'; @@ -1142,3 +1150,88 @@ describe('backup-date-evidence connected inventory', () => { expect(within(coverageRow()).getByText('Protected')).toBeInTheDocument(); }); }); + +describe('backup-identity: connected Coverage and By date', () => { + const mockIdentityAPIs = () => { + apiFetchMock.mockImplementation((url: string) => + Promise.resolve( + jsonResponse({ + data: + url === '/api/backups/pve' + ? { + storageBackups: [identityArchive()], + guestSnapshots: [identitySnapshot()], + backupTasks: [identityTask()], + } + : { backups: [identityPBS()] }, + }), + ), + ); + apiFetchJSONMock.mockResolvedValue({ data: [], policy: {}, meta: {} }); + vi.spyOn(Date, 'now').mockReturnValue(BACKUP_IDENTITY_NOW); + }; + afterEach(() => vi.restoreAllMocks()); + + it('keeps all three artifacts and the successful task with the scoped owner through reorder and display rename', async () => { + mockIdentityAPIs(); + window.history.replaceState({}, '', '/proxmox/backups/coverage'); + const east = identityGuest('east-100', 'east', 'pve-1'); + const west = identityGuest('west-100', 'west', 'pve-10'); + const [workloads, setWorkloads] = createSignal([west, east]); + const { container } = renderInRouter(() => ( + } workloads={workloads()} /> + )); + const rowFor = (name: string) => + Array.from(container.querySelectorAll('[data-proxmox-backup-row="coverage"]')).find((row) => + row.textContent?.includes(name), + ) as HTMLElement; + await waitFor(() => expect(rowFor('east-100')).toHaveTextContent('OK')); + expect(rowFor('west-100')).not.toHaveTextContent('OK'); + expect(within(rowFor('west-100')).getByTitle('No recent task')).toBeInTheDocument(); + const toggle = within(rowFor('east-100')).getByRole('button', { name: /expand details/i }); + await fireEvent.click(toggle); + const detailID = toggle.getAttribute('aria-controls')!; + await waitFor(() => expect(document.getElementById(detailID)).toHaveTextContent('Snapshot')); + expect(document.getElementById(detailID)).toHaveTextContent('PVE file'); + expect(document.getElementById(detailID)).toHaveTextContent('PBS'); + setWorkloads([east, { ...west, proxmox: { ...west.proxmox!, nodeDisplayName: 'pve-1' } }]); + await waitFor(() => expect(rowFor('east-100')).toHaveTextContent('OK')); + expect(within(rowFor('east-100')).getByRole('button', { name: /collapse details/i })).toBe( + toggle, + ); + expect(rowFor('west-100')).not.toHaveTextContent('OK'); + expect(document.getElementById(detailID)).toHaveTextContent('PVE file'); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pve')).toHaveLength(1); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1); + }); + + it('withdraws ambiguous PBS ownership without losing the artifact, PVE owner or server posture', async () => { + mockIdentityAPIs(); + window.history.replaceState({}, '', '/proxmox/backups/date'); + const east = identityGuest('east-100', 'east', 'pve-1'); + const west = identityGuest('west-100', 'west', 'pve-10'); + const [workloads, setWorkloads] = createSignal([west, east]); + const { container } = renderInRouter(() => ( + } workloads={workloads()} /> + )); + const artifacts = () => + Array.from(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')); + await waitFor(() => expect(artifacts()).toHaveLength(3)); + expect(artifacts().every((row) => row.textContent?.includes('east-100'))).toBe(true); + setWorkloads([identityGuest('west-100', 'west', 'pve-1'), east]); + await waitFor(() => + expect(artifacts().filter((row) => row.textContent?.includes('east-100'))).toHaveLength(2), + ); + const unresolved = artifacts().find((row) => !row.textContent?.includes('east-100'))!; + expect(unresolved).toHaveTextContent('VM 100'); + expect(unresolved).toHaveTextContent('PBS'); + expect(artifacts()).toHaveLength(3); + await fireEvent.click(screen.getByRole('link', { name: 'Coverage' })); + await fireEvent.click(screen.getByRole('button', { name: /1 unmatched backup/ })); + await waitFor(() => + expect(screen.getByText('Not evaluated', { exact: true })).toBeInTheDocument(), + ); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pve')).toHaveLength(1); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts index 6ab7884a78..9ec3528ff7 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts @@ -141,11 +141,11 @@ describe('canonical protection posture ownership', () => { }); // --------------------------------------------------------------------------- -// matchWorkloadByHints — all branches (private; exercised through tasks whose +// matchPVEWorkload — all branches (private; exercised through tasks whose // type maps to "unknown", e.g. vzdump, forcing the hint-matching path). // --------------------------------------------------------------------------- -describe('matchWorkloadByHints branches (via vzdump tasks)', () => { +describe('matchPVEWorkload branches (via vzdump tasks)', () => { it('matches prior native node aliases while presenting the current display name', () => { const model = buildModel({ workloads: [ @@ -210,7 +210,7 @@ describe('matchWorkloadByHints branches (via vzdump tasks)', () => { expect(model.coverageRows.every((row) => row.latestTask === undefined)).toBe(true); }); - it('falls back to the only candidate when its hints do not match', () => { + it('leaves the only candidate untouched when the native task node contradicts it', () => { const model = buildModel({ workloads: [workload({ id: 'vm-440', proxmox: { vmid: 440, node: 'alpha' } })], tasks: [ @@ -224,7 +224,7 @@ describe('matchWorkloadByHints branches (via vzdump tasks)', () => { }), ], }); - expect(model.coverageRows[0].latestTask?.label).toBe('OK'); + expect(model.coverageRows[0].latestTask).toBeUndefined(); }); it('falls back to the only candidate when hints are all blank', () => { diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts index 0539bfcbab..20928fe043 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts @@ -26,36 +26,63 @@ const owners = (result: ReturnType) => describe('backup-identity: native ownership rather than first label match', () => { it.each([false, true])('is independent of inventory order (reversed: %s)', (reversed) => { - const workloads = [identityGuest('west-100', 'west', 'pve-10'), identityGuest('east-100', 'east', 'pve-1')]; + const workloads = [ + identityGuest('west-100', 'west', 'pve-10'), + identityGuest('east-100', 'east', 'pve-1'), + ]; if (reversed) workloads.reverse(); - const result = model({ workloads, archives: [identityArchive()], snapshots: [identitySnapshot()], pbsBackups: [identityPBS()], tasks: [identityTask()] }); + const result = model({ + workloads, + archives: [identityArchive()], + snapshots: [identitySnapshot()], + pbsBackups: [identityPBS()], + tasks: [identityTask()], + }); expect(owners(result)).toEqual(['east-100', 'east-100', 'east-100']); - expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined(); - expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask, + ).toBeUndefined(); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id, + ).toBe('task-100'); }); it('uses the PVE instance before a reused node name', () => { const result = model({ - workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], - archives: [identityArchive()], snapshots: [identitySnapshot()], tasks: [identityTask()], + workloads: [ + identityGuest('west-100', 'west', 'pve-1'), + identityGuest('east-100', 'east', 'pve-1'), + ], + archives: [identityArchive()], + snapshots: [identitySnapshot()], + tasks: [identityTask()], }); expect(owners(result)).toEqual(['east-100', 'east-100']); - expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined(); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask, + ).toBeUndefined(); }); it('keeps same-instance migrated backups without using the old node to select another cluster', () => { const result = model({ - workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-2')], - archives: [identityArchive()], tasks: [identityTask()], + workloads: [ + identityGuest('west-100', 'west', 'pve-1'), + identityGuest('east-100', 'east', 'pve-2'), + ], + archives: [identityArchive()], + tasks: [identityTask()], }); expect(owners(result)).toEqual(['east-100']); - expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id, + ).toBe('task-100'); }); it('does not let a matching node override a conflicting PVE instance, even with one visible guest', () => { const result = model({ workloads: [identityGuest('east-100', 'east', 'pve-1')], - archives: [identityArchive({ instance: 'removed-east' })], snapshots: [identitySnapshot({ instance: 'removed-east' })], + archives: [identityArchive({ instance: 'removed-east' })], + snapshots: [identitySnapshot({ instance: 'removed-east' })], tasks: [identityTask({ instance: 'removed-east' })], }); expect(owners(result)).toEqual([undefined, undefined]); @@ -70,54 +97,160 @@ describe('backup-identity: native ownership rather than first label match', () = it('does not accept a partial node match or contradictory singleton when PVE instance is absent', () => { const result = model({ workloads: [identityGuest('west-100', '', 'pve-10')], - archives: [identityArchive({ instance: '' })], snapshots: [identitySnapshot({ instance: '' })], tasks: [identityTask({ instance: '' })], + archives: [identityArchive({ instance: '' })], + snapshots: [identitySnapshot({ instance: '' })], + tasks: [identityTask({ instance: '' })], }); expect(owners(result)).toEqual([undefined, undefined]); expect(result.coverageRows.find((row) => row.workload.resourceId)?.latestTask).toBeUndefined(); }); it('accepts exact native aliases, not mutable display labels', () => { - const workloads = [identityGuest('west-100', 'west', 'pve-10', { nodeDisplayName: 'pve-1' }), identityGuest('east-100', 'east', 'pve-new', { nodeAliases: ['pve-1'] })]; - const result = model({ workloads, archives: [identityArchive({ instance: '' })], pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] }); + const workloads = [ + identityGuest('west-100', 'west', 'pve-10', { nodeDisplayName: 'pve-1' }), + identityGuest('east-100', 'east', 'pve-new', { nodeAliases: ['pve-1'] }), + ]; + const result = model({ + workloads, + archives: [identityArchive({ instance: '' })], + pbsBackups: [identityPBS()], + tasks: [identityTask({ instance: '' })], + }); expect(owners(result)).toEqual(['east-100', 'east-100']); - expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id, + ).toBe('task-100'); + }); + + it('keeps presentation-only parent labels out of native-node matching', () => { + const misleading = { ...identityGuest('west-100', '', ''), parentName: 'pve-1' }; + const result = model({ + workloads: [misleading, identityGuest('east-100', '', 'pve-1')], + archives: [identityArchive({ instance: '' })], + pbsBackups: [identityPBS()], + }); + expect(owners(result)).toEqual(['east-100', 'east-100']); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.workload.node, + ).toBe('pve-1'); + expect( + result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.workload + .nativeNode, + ).toBeUndefined(); + }); + + it('does not collide unresolved repository keys on case or delimiter framing', () => { + const result = model({ + pbsBackups: [ + identityPBS({ id: 'case-a', instance: 'pbs', datastore: 'main' }), + identityPBS({ id: 'case-b', instance: 'pbs', datastore: 'Main' }), + identityPBS({ id: 'parts-a', instance: 'a:b', datastore: 'c' }), + identityPBS({ id: 'parts-b', instance: 'a', datastore: 'b:c' }), + ], + }); + expect(result.coverageRows).toHaveLength(4); + expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(4); + }); + + it('keeps source names with spaces or quotes in an IDREF-safe unresolved key', () => { + const result = model({ + pbsBackups: [ + identityPBS({ + instance: 'backup server', + datastore: 'main "store"', + namespace: 'old node', + }), + ], + }); + const key = result.coverageRows[0].key; + expect(key).not.toMatch(/[\s"']/); + expect(decodeURIComponent(key)).toContain('backup server'); }); it('does not turn a reused namespace/node or alias into a unique PBS owner', () => { - const result = model({ workloads: [identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }), identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] })], pbsBackups: [identityPBS()] }); + const result = model({ + workloads: [ + identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }), + identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] }), + ], + pbsBackups: [identityPBS()], + }); expect(owners(result)).toEqual([undefined]); - expect(result.coverageRows.filter((row) => row.workload.resourceId).every((row) => row.pbsCount === 0)).toBe(true); + expect( + result.coverageRows + .filter((row) => row.workload.resourceId) + .every((row) => row.pbsCount === 0), + ).toBe(true); }); it('does not use a datastore label as a PVE workload identity', () => { - const result = model({ workloads: [identityGuest('west-100', 'west', 'main'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] }); + const result = model({ + workloads: [ + identityGuest('west-100', 'west', 'main'), + identityGuest('east-100', 'east', 'pve-1'), + ], + pbsBackups: [identityPBS({ namespace: '' })], + }); expect(owners(result)).toEqual([undefined]); }); it('preserves root-namespace PBS for a unique VMID and exact instance namespace with collisions', () => { - expect(owners(model({ workloads: [identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] }))).toEqual(['east-100']); - expect(owners(model({ workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: ' EAST ' })] }))).toEqual(['east-100']); + expect( + owners( + model({ + workloads: [identityGuest('east-100', 'east', 'pve-1')], + pbsBackups: [identityPBS({ namespace: '' })], + }), + ), + ).toEqual(['east-100']); + expect( + owners( + model({ + workloads: [ + identityGuest('west-100', 'west', 'pve-1'), + identityGuest('east-100', 'east', 'pve-1'), + ], + pbsBackups: [identityPBS({ namespace: ' EAST ' })], + }), + ), + ).toEqual(['east-100']); }); it('retains exact-node matching without instance metadata and the unscoped singleton control', () => { const workload = identityGuest('east-100', '', 'pve-1'); - expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '' })] }))).toEqual(['east-100']); - expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '', node: '' })] }))).toEqual(['east-100']); + expect( + owners(model({ workloads: [workload], archives: [identityArchive({ instance: '' })] })), + ).toEqual(['east-100']); + expect( + owners( + model({ workloads: [workload], archives: [identityArchive({ instance: '', node: '' })] }), + ), + ).toEqual(['east-100']); }); it('leaves an ambiguous unknown-type task unattached across VM and LXC identities', () => { const vm = identityGuest('vm-100', 'east', 'pve-1'); - const ct = { ...identityGuest('ct-100', 'east', 'pve-1'), type: 'system-container' } as typeof vm; + const ct = { + ...identityGuest('ct-100', 'east', 'pve-1'), + type: 'system-container', + } as typeof vm; const result = model({ workloads: [vm, ct], tasks: [identityTask()] }); expect(result.coverageRows.every((row) => row.latestTask === undefined)).toBe(true); }); it('preserves unresolved artifacts but does not pool identically named PBS sources', () => { - const result = model({ pbsBackups: [identityPBS({ id: 'pbs-a', instance: 'pbs-a' }), identityPBS({ id: 'pbs-b', instance: 'pbs-b' })] }); + const result = model({ + pbsBackups: [ + identityPBS({ id: 'pbs-a', instance: 'pbs-a' }), + identityPBS({ id: 'pbs-b', instance: 'pbs-b' }), + ], + }); expect(result.recoverableArtifacts).toHaveLength(2); expect(result.coverageRows).toHaveLength(2); expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(2); - expect(result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated')).toBe(true); + expect( + result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated'), + ).toBe(true); }); it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => { @@ -126,8 +259,19 @@ describe('backup-identity: native ownership rather than first label match', () = }); it('keeps unresolved PVE instances separate while associating their own typed and untyped tasks', () => { - const result = model({ archives: [identityArchive({ id: 'file-east' }), identityArchive({ id: 'file-west', instance: 'west' })], tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })] }); + const result = model({ + archives: [ + identityArchive({ id: 'file-east' }), + identityArchive({ id: 'file-west', instance: 'west' }), + ], + tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })], + }); expect(result.coverageRows).toHaveLength(2); - expect(result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort()).toEqual([['east', 'task-100'], ['west', 'task-west']]); + expect( + result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort(), + ).toEqual([ + ['east', 'task-100'], + ['west', 'task-west'], + ]); }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index c7873682de..25a1cb00ac 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -442,16 +442,21 @@ describe('proxmoxBackupRecoveryModel', () => { }); expect(model.recoverableArtifacts).toHaveLength(3); - expect(model.coverageRows).toHaveLength(1); - expect(model.coverageSummary.totalWorkloads).toBe(1); - expect(model.coverageRows[0].isOrphaned).toBe(false); - expect(model.coverageRows[0].workload.type).toBe('host'); - expect(model.coverageRows[0].workload.vmid).toBe('mail-gateway'); - expect(model.coverageRows[0].workload.label).toBe('Host mail-gateway'); - expect(model.coverageRows[0].posture).toBe('not-evaluated'); - expect(getWorkloadRecoveryPostureLabel(model.coverageRows[0].posture)).toBe('Not evaluated'); - expect(model.coverageRows[0].pbsCount).toBe(1); - expect(model.coverageRows[0].archiveCount).toBe(1); + expect(model.coverageRows).toHaveLength(2); + expect(model.coverageSummary.totalWorkloads).toBe(2); + // Equal host labels on PVE and PBS are not proof of the same backup target. + expect(model.coverageRows.every((row) => !row.isOrphaned && row.workload.type === 'host')).toBe( + true, + ); + expect( + model.coverageRows.every( + (row) => row.workload.label === 'Host mail-gateway' && row.posture === 'not-evaluated', + ), + ).toBe(true); + expect(model.coverageRows.map((row) => [row.pbsCount, row.archiveCount]).sort()).toEqual([ + [0, 1], + [1, 0], + ]); expect(model.recoverableArtifacts.map((artifact) => artifact.workload.label)).toEqual( expect.arrayContaining(['LXC backup', 'Host mail-gateway']), ); diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts index 3f0262a4a6..d75fe771e2 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts @@ -93,7 +93,7 @@ export interface WorkloadCoverageRow { postureRank: number; protectionPosture?: ProtectionPosture; // True only when a VM/LXC row exists because a backup/task referenced a VMID - // with no matching live inventory guest. Host backups can also carry a + // with no unambiguous live inventory match (absent or conflicting). Host backups can also carry a // `backup:` key, but they are first-class backup targets, not orphaned guests. isOrphaned: boolean; } @@ -124,10 +124,7 @@ interface BuildModelInput { protectionPosturesResolved?: boolean; } -interface WorkloadCandidate extends WorkloadReference { - nodeKey?: string; - instanceKey?: string; -} +type WorkloadCandidate = WorkloadReference; type WorkloadRowDraft = Omit; @@ -266,7 +263,7 @@ function resourceBackupType(resource: Resource): WorkloadReference['type'] { } function resourceNode(resource: Resource): string | undefined { - return resource.proxmox?.nodeName || resource.proxmox?.node || resource.parentName || undefined; + return resource.proxmox?.nodeName || resource.proxmox?.node || undefined; } function resourceNodeDisplayName(resource: Resource): string | undefined { @@ -316,8 +313,6 @@ function buildCandidateFromResource(resource: Resource): WorkloadCandidate | nul nativeNode, nativeNodeAliases, instance, - nodeKey: normalizeKey(nativeNode), - instanceKey: normalizeKey(instance), }; } @@ -325,18 +320,24 @@ function fallbackWorkload( type: WorkloadReference['type'], vmid: string, hints: readonly (string | undefined)[], + sourceScope: readonly (string | undefined)[], + pveInstance?: string, ): WorkloadReference { const id = fallbackWorkloadId(type, vmid, hints); - const scope = hints.map(normalizeKey).find(Boolean) || 'unknown'; - const keyScope = type === 'host' && id ? 'host' : scope; + // A VMID (or host label) and repository name are not cross-source identity. + // Keep unresolved evidence listed, with a collision-safe complete source key. + const scope = encodeURIComponent( + JSON.stringify(sourceScope.map((part) => String(part ?? '').trim())), + ); return { - key: `backup:${type}:${id || 'unknown'}:${keyScope}`, + key: `backup:${type}:${id || 'unknown'}:${scope}`, type, typeLabel: typeLabel(type), vmid: id, label: workloadFallbackLabel(type, id), node: type === 'host' ? undefined : hints.find((hint) => !!hint?.trim()), nativeNode: type === 'host' ? undefined : hints.find((hint) => !!hint?.trim()), + instance: pveInstance, }; } @@ -373,72 +374,118 @@ function buildWorkloadCandidateIndex( return { byTypeAndVmid, byVmid }; } -function resolveWorkload( +function uniqueWorkload(matches: readonly T[]): T | undefined { + return matches.length === 1 ? matches[0] : undefined; +} + +function matchesNativeNode(workload: WorkloadReference, node: string): boolean { + const key = normalizeKey(node); + return ( + Boolean(key) && + [workload.nativeNode, ...(workload.nativeNodeAliases ?? [])].some( + (name) => normalizeKey(name) === key, + ) + ); +} + +function matchPVEWorkload( + workloads: readonly T[], + instance: string, + node: string, +): T | undefined { + const instanceKey = normalizeKey(instance); + const nodeKey = normalizeKey(node); + if (instanceKey) { + const scoped = workloads.filter((workload) => normalizeKey(workload.instance) === instanceKey); + // A VMID is unique inside its PVE connection. Historical backup/task nodes + // can differ after a migration; they must not override that connection. + if (scoped.length > 0) { + return ( + uniqueWorkload(scoped) ?? + uniqueWorkload(scoped.filter((workload) => matchesNativeNode(workload, node))) + ); + } + // Older projections may omit the instance. An exact native node can still + // identify one of them, but cannot rescue a known conflicting instance. + return uniqueWorkload( + workloads.filter( + (workload) => !normalizeKey(workload.instance) && matchesNativeNode(workload, node), + ), + ); + } + if (nodeKey) + return uniqueWorkload(workloads.filter((workload) => matchesNativeNode(workload, node))); + return uniqueWorkload(workloads); +} + +function resolvePVEWorkload( candidates: WorkloadCandidateIndex, type: WorkloadReference['type'], vmid: string, - hints: readonly (string | undefined)[], + instance: string, + node: string, ): WorkloadReference { const typed = candidates.byTypeAndVmid.get(workloadTypeVmidKey(type, vmid)) ?? []; - const normalizedHints = hints.map(normalizeKey).filter(Boolean); - if (typed.length > 0 && normalizedHints.length > 0) { - const exact = typed.find((candidate) => - normalizedHints.some( - (hint) => - hint === candidate.nodeKey || - hint === candidate.instanceKey || - normalizeKey(candidate.node).includes(hint) || - normalizeKey(candidate.nativeNode).includes(hint) || - candidate.nativeNodeAliases?.some((alias) => normalizeKey(alias).includes(hint)) || - normalizeKey(candidate.instance).includes(hint), - ), - ); - if (exact) return exact; - } - if (typed.length === 1) return typed[0]; - return fallbackWorkload(type, vmid, hints); + return ( + matchPVEWorkload(typed, instance, node) ?? + fallbackWorkload( + type, + vmid, + type === 'host' ? [instance, node] : [node, instance], + ['pve', instance, node], + instance, + ) + ); } -function matchWorkloadByHints( - workloads: readonly T[], - hints: readonly (string | undefined)[], -): T | undefined { - const normalizedHints = hints.map(normalizeKey).filter(Boolean); - if (workloads.length > 0 && normalizedHints.length > 0) { - const exact = workloads.find((workload) => - normalizedHints.some( - (hint) => - hint === normalizeKey(workload.node) || - hint === normalizeKey(workload.nativeNode) || - hint === normalizeKey(workload.instance) || - normalizeKey(workload.node).includes(hint) || - normalizeKey(workload.nativeNode).includes(hint) || - workload.nativeNodeAliases?.some((alias) => normalizeKey(alias).includes(hint)) || - normalizeKey(workload.instance).includes(hint), - ), - ); - if (exact) return exact; - } - if (workloads.length === 1) return workloads[0]; - return undefined; +function resolvePBSWorkload( + candidates: WorkloadCandidateIndex, + backup: PBSBackup, +): WorkloadReference { + const type = backupTypeLabel(backup.backupType); + const typed = candidates.byTypeAndVmid.get(workloadTypeVmidKey(type, backup.vmid)) ?? []; + // PBS instance/datastore identify a repository, not a PVE connection. A + // namespace can disambiguate a repeated VMID only with one exact native + // node/alias or connection match. A display name or substring proves none. + const namespace = normalizeKey(backup.namespace); + const matched = namespace + ? uniqueWorkload( + typed.filter( + (workload) => + matchesNativeNode(workload, backup.namespace) || + normalizeKey(workload.instance) === namespace, + ), + ) + : undefined; + return ( + matched ?? + uniqueWorkload(typed) ?? + fallbackWorkload( + type, + backup.vmid, + [backup.namespace, backup.datastore], + ['pbs', backup.instance, backup.datastore, backup.namespace], + ) + ); } function resolveTaskWorkload( candidates: WorkloadCandidateIndex, - workloadsByVmid: ReadonlyMap, + pveWorkloadsByVmid: ReadonlyMap, task: BackupTask, ): WorkloadReference | undefined { const vmid = String(task.vmid); if (!vmid || isZeroWorkloadId(vmid)) return undefined; const explicitType = backupTypeLabel(task.type); - const hints = [task.instance, task.node]; - if (explicitType !== 'unknown') return resolveWorkload(candidates, explicitType, vmid, hints); + if (explicitType !== 'unknown') + return resolvePVEWorkload(candidates, explicitType, vmid, task.instance, task.node); - const candidate = matchWorkloadByHints(candidates.byVmid.get(vmid) ?? [], hints); + const candidate = matchPVEWorkload(candidates.byVmid.get(vmid) ?? [], task.instance, task.node); if (candidate) return candidate; - - return matchWorkloadByHints(workloadsByVmid.get(vmid) ?? [], hints); + // Only PVE-backed orphan rows share this source scope. A PBS namespace that + // happens to resemble the task node is not evidence that the task owns it. + return matchPVEWorkload(pveWorkloadsByVmid.get(vmid) ?? [], task.instance, task.node); } function newest(items: readonly T[], getMs: (item: T) => number | undefined): T | undefined { @@ -549,9 +596,9 @@ export function buildProxmoxBackupRecoveryModel( .filter((candidate): candidate is WorkloadCandidate => candidate !== null); const candidateIndex = buildWorkloadCandidateIndex(candidates); const rows = new Map(); - const workloadsByVmid = new Map(); + const pveWorkloadsByVmid = new Map(); - const ensureRow = (workload: WorkloadReference) => { + const ensureRow = (workload: WorkloadReference, pveSource: boolean) => { const existing = rows.get(workload.key); if (existing) return existing; const row: WorkloadRowDraft = { @@ -563,13 +610,15 @@ export function buildProxmoxBackupRecoveryModel( snapshotCount: 0, }; rows.set(workload.key, row); - const matchingVmid = workloadsByVmid.get(workload.vmid); - if (matchingVmid) matchingVmid.push(workload); - else workloadsByVmid.set(workload.vmid, [workload]); + if (pveSource) { + const matchingVmid = pveWorkloadsByVmid.get(workload.vmid); + if (matchingVmid) matchingVmid.push(workload); + else pveWorkloadsByVmid.set(workload.vmid, [workload]); + } return row; }; - for (const candidate of candidates) ensureRow(candidate); + for (const candidate of candidates) ensureRow(candidate, true); const artifacts: RecoverableArtifact[] = []; const pbsSource = getProxmoxBackupSourcePresentation('pbs'); @@ -578,7 +627,7 @@ export function buildProxmoxBackupRecoveryModel( const addArtifact = (artifact: RecoverableArtifact) => { artifacts.push(artifact); if (!isCoverageWorkload(artifact.workload)) return; - const row = ensureRow(artifact.workload); + const row = ensureRow(artifact.workload, artifact.sourceKind !== 'pbs'); row.artifacts.push(artifact); if (artifact.sourceKind === 'pbs') row.pbsCount += 1; else if (artifact.sourceKind === 'archive') row.archiveCount += 1; @@ -586,11 +635,7 @@ export function buildProxmoxBackupRecoveryModel( }; for (const backup of input.pbsBackups) { - const type = backupTypeLabel(backup.backupType); - const workload = resolveWorkload(candidateIndex, type, backup.vmid, [ - backup.namespace, - backup.datastore, - ]); + const workload = resolvePBSWorkload(candidateIndex, backup); const parsedMs = parseTimestampMs(backup.backupTime); const createdMs = getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs; @@ -624,10 +669,13 @@ export function buildProxmoxBackupRecoveryModel( for (const archive of input.archives) { const type = backupTypeLabel(archive.type); - const workload = resolveWorkload(candidateIndex, type, String(archive.vmid), [ + const workload = resolvePVEWorkload( + candidateIndex, + type, + String(archive.vmid), archive.instance, archive.node, - ]); + ); const parsedMs = parseTimestampMs(archive.time); const createdMs = getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs; @@ -657,10 +705,13 @@ export function buildProxmoxBackupRecoveryModel( for (const snapshot of input.snapshots) { const type = backupTypeLabel(snapshot.type); - const workload = resolveWorkload(candidateIndex, type, String(snapshot.vmid), [ + const workload = resolvePVEWorkload( + candidateIndex, + type, + String(snapshot.vmid), snapshot.instance, snapshot.node, - ]); + ); const parsedMs = parseTimestampMs(snapshot.time); const createdMs = getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs; @@ -682,9 +733,9 @@ export function buildProxmoxBackupRecoveryModel( } for (const task of input.tasks) { - const workload = resolveTaskWorkload(candidateIndex, workloadsByVmid, task); + const workload = resolveTaskWorkload(candidateIndex, pveWorkloadsByVmid, task); if (!workload) continue; - const row = ensureRow(workload); + const row = ensureRow(workload, true); const candidateTask: CoverageTask = { id: task.id, status: task.status,