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/__fixtures__/backupIdentity.ts b/frontend-modern/src/features/proxmox/__fixtures__/backupIdentity.ts
new file mode 100644
index 0000000000..522a13dd2e
--- /dev/null
+++ b/frontend-modern/src/features/proxmox/__fixtures__/backupIdentity.ts
@@ -0,0 +1,79 @@
+import type { BackupTask, GuestSnapshot, PBSBackup, StorageBackup } from '@/types/api';
+import type { Resource } from '@/types/resource';
+
+export const BACKUP_IDENTITY_NOW = Date.parse('2026-10-04T12:00:00Z');
+
+export const identityGuest = (
+ id: string,
+ instance: string,
+ node: string,
+ extra: Partial
> = {},
+): Resource =>
+ ({
+ id,
+ name: id,
+ type: 'vm',
+ platformId: instance,
+ platformType: 'proxmox-pve',
+ sourceType: 'api',
+ status: 'running',
+ lastSeen: BACKUP_IDENTITY_NOW,
+ proxmox: { vmid: 100, instance, nodeName: node, ...extra },
+ }) as Resource;
+
+export const identityArchive = (extra: Partial = {}): StorageBackup => ({
+ id: 'file-100',
+ instance: 'east',
+ node: 'pve-1',
+ type: 'vm',
+ vmid: 100,
+ storage: 'local',
+ time: '2026-10-04T11:00:00Z',
+ ctime: BACKUP_IDENTITY_NOW / 1000 - 3600,
+ size: 1024,
+ format: 'zst',
+ volid: 'local:backup/vzdump-qemu-100.vma.zst',
+ protected: false,
+ isPBS: false,
+ verified: false,
+ ...extra,
+});
+
+export const identitySnapshot = (extra: Partial = {}): GuestSnapshot => ({
+ id: 'snapshot-100',
+ instance: 'east',
+ node: 'pve-1',
+ type: 'vm',
+ vmid: 100,
+ name: 'before-update',
+ time: '2026-10-04T11:30:00Z',
+ vmstate: false,
+ ...extra,
+});
+
+export const identityPBS = (extra: Partial = {}): PBSBackup => ({
+ id: 'pbs-100',
+ instance: 'pbs-server',
+ namespace: 'pve-1',
+ datastore: 'main',
+ backupType: 'vm',
+ vmid: '100',
+ backupTime: '2026-10-04T10:00:00Z',
+ size: 2048,
+ files: ['index.json.blob'],
+ protected: false,
+ verified: true,
+ ...extra,
+});
+
+export const identityTask = (extra: Partial = {}): BackupTask => ({
+ 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',
+ ...extra,
+});
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
new file mode 100644
index 0000000000..20928fe043
--- /dev/null
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts
@@ -0,0 +1,277 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ BACKUP_IDENTITY_NOW,
+ identityArchive,
+ identityGuest,
+ identityPBS,
+ identitySnapshot,
+ identityTask,
+} from '../__fixtures__/backupIdentity';
+import { buildProxmoxBackupRecoveryModel } from '../proxmoxBackupRecoveryModel';
+
+type Input = Parameters[0];
+const model = (input: Partial) =>
+ buildProxmoxBackupRecoveryModel({
+ workloads: [],
+ archives: [],
+ snapshots: [],
+ pbsBackups: [],
+ tasks: [],
+ nowMs: BACKUP_IDENTITY_NOW,
+ ...input,
+ });
+const owners = (result: ReturnType) =>
+ result.recoverableArtifacts.map((artifact) => artifact.workload.resourceId);
+
+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'),
+ ];
+ if (reversed) workloads.reverse();
+ 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');
+ });
+
+ 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()],
+ });
+ expect(owners(result)).toEqual(['east-100', 'east-100']);
+ 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()],
+ });
+ expect(owners(result)).toEqual(['east-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' })],
+ tasks: [identityTask({ instance: 'removed-east' })],
+ });
+ expect(owners(result)).toEqual([undefined, undefined]);
+ const live = result.coverageRows.find((row) => row.workload.resourceId === 'east-100')!;
+ expect(live.artifacts).toEqual([]);
+ expect(live.latestTask).toBeUndefined();
+ const orphan = result.coverageRows.find((row) => row.isOrphaned)!;
+ expect(orphan.latestTask?.id).toBe('task-100');
+ expect(orphan.posture).toBe('not-evaluated');
+ });
+
+ 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: '' })],
+ });
+ 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: '' })],
+ });
+ expect(owners(result)).toEqual(['east-100', 'east-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()],
+ });
+ expect(owners(result)).toEqual([undefined]);
+ 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: '' })],
+ });
+ 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']);
+ });
+
+ 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']);
+ });
+
+ 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 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' }),
+ ],
+ });
+ 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);
+ });
+
+ it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => {
+ const result = model({ pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] });
+ expect(result.coverageRows[0].latestTask).toBeUndefined();
+ });
+
+ 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' })],
+ });
+ expect(result.coverageRows).toHaveLength(2);
+ 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,