Integrate reviewed native backup ownership correction

Retain c5e83ceb and 6198a3ce exactly; preserve current main and verified frontend content.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 14:59:42 +01:00
13 changed files with 1121 additions and 98 deletions
@@ -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
@@ -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
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup ownership verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./backup-identity.tsx"></script>
</body>
</html>
@@ -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<Resource[]>([
identityGuest('west-100', 'west', 'pve-10'),
identityGuest('east-100', 'east', 'pve-1'),
]);
onMount(() => {
Object.assign(window, { __replaceBackupIdentityWorkloads: setWorkloads });
onCleanup(() => Reflect.deleteProperty(window, '__replaceBackupIdentityWorkloads'));
});
return (
<main class="p-3 sm:p-6">
<ProxmoxBackupsTable emptyIcon={<span />} workloads={workloads()} />
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -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."
]
}
@@ -256,9 +256,9 @@ export const ProxmoxBackupsTable: Component<{
const [expandedCoverageRows, setExpandedCoverageRows] = createSignal<ReadonlySet<string>>(
new Set<string>(),
);
// 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<{
</Show>
<Show when={orphanedTotalCount() > 0}>
{' · '}
{orphanedTotalCount()} orphaned
{orphanedTotalCount()} unmatched
</Show>
</span>
}
@@ -936,7 +936,7 @@ export const ProxmoxBackupsTable: Component<{
<button
type="button"
onClick={() => setShowOrphaned((v) => !v)}
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left"
class="flex w-full flex-wrap items-center justify-between gap-2 px-3 py-2 text-left"
aria-expanded={showOrphaned()}
>
<span class="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
@@ -944,10 +944,12 @@ export const ProxmoxBackupsTable: Component<{
class={`h-3.5 w-3.5 transition-transform ${showOrphaned() ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
{orphanedTotalCount()} orphaned{' '}
{orphanedTotalCount()} unmatched{' '}
{orphanedTotalCount() === 1 ? 'backup' : 'backups'}
</span>
<span class="text-[11px] text-muted">guest no longer exists in inventory</span>
<span class="w-full text-[11px] text-muted sm:w-auto">
No unambiguous inventory match
</span>
</button>
<Show when={showOrphaned()}>
<div class="border-t border-border-subtle p-2">
@@ -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}
@@ -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<NonNullable<Resource['proxmox']>> = {},
): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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,
});
@@ -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(() => (
<ProxmoxBackupsTable emptyIcon={<span />} 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(() => (
<ProxmoxBackupsTable emptyIcon={<span />} 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);
});
});
@@ -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', () => {
@@ -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<typeof buildProxmoxBackupRecoveryModel>[0];
const model = (input: Partial<Input>) =>
buildProxmoxBackupRecoveryModel({
workloads: [],
archives: [],
snapshots: [],
pbsBackups: [],
tasks: [],
nowMs: BACKUP_IDENTITY_NOW,
...input,
});
const owners = (result: ReturnType<typeof model>) =>
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'],
]);
});
});
@@ -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']),
);
@@ -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<WorkloadCoverageRow, 'posture' | 'postureRank' | 'isOrphaned'>;
@@ -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<T>(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<T extends WorkloadReference>(
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<T extends WorkloadReference>(
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<string, readonly WorkloadReference[]>,
pveWorkloadsByVmid: ReadonlyMap<string, readonly WorkloadReference[]>,
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<T>(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<string, WorkloadRowDraft>();
const workloadsByVmid = new Map<string, WorkloadReference[]>();
const pveWorkloadsByVmid = new Map<string, WorkloadReference[]>();
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,