mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Keep Proxmox backup evidence with its native source
Replace order-dependent substring and display-name attribution with exact PVE connection/native-node ownership and unique PBS namespace matching. Preserve ambiguous artifacts in distinct source-scoped, IDREF-safe rows and explain unmatched evidence without claiming that the guest was deleted. Keep migrated backups, independent server posture and unique-VMID PBS compatibility. Include connected controls, dependent contracts and inspected desktop/phone browser proof. Change-source: pulse-maintainer
This commit is contained in:
@@ -20,6 +20,45 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Diagnostics are live checks; downloads require a privacy review
|
||||||
|
|
||||||
The existing Settings Diagnostics panel explains before either run action that
|
The existing Settings Diagnostics panel explains before either run action that
|
||||||
|
|||||||
@@ -21,6 +21,45 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### PBS datastore capacity is not inferred from failed-read zeros
|
||||||
|
|
||||||
The existing Backups server table distinguishes a named datastore whose status
|
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>>(
|
const [expandedCoverageRows, setExpandedCoverageRows] = createSignal<ReadonlySet<string>>(
|
||||||
new Set<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
|
// 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);
|
const [showOrphaned, setShowOrphaned] = createSignal(false);
|
||||||
|
|
||||||
// Chronological feed sort and activity-chart controls.
|
// Chronological feed sort and activity-chart controls.
|
||||||
@@ -746,7 +746,7 @@ export const ProxmoxBackupsTable: Component<{
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={orphanedTotalCount() > 0}>
|
<Show when={orphanedTotalCount() > 0}>
|
||||||
{' · '}
|
{' · '}
|
||||||
{orphanedTotalCount()} orphaned
|
{orphanedTotalCount()} unmatched
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -936,7 +936,7 @@ export const ProxmoxBackupsTable: Component<{
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowOrphaned((v) => !v)}
|
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()}
|
aria-expanded={showOrphaned()}
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
<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' : ''}`}
|
class={`h-3.5 w-3.5 transition-transform ${showOrphaned() ? 'rotate-90' : ''}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{orphanedTotalCount()} orphaned{' '}
|
{orphanedTotalCount()} unmatched{' '}
|
||||||
{orphanedTotalCount() === 1 ? 'backup' : 'backups'}
|
{orphanedTotalCount() === 1 ? 'backup' : 'backups'}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
<Show when={showOrphaned()}>
|
<Show when={showOrphaned()}>
|
||||||
<div class="border-t border-border-subtle p-2">
|
<div class="border-t border-border-subtle p-2">
|
||||||
@@ -955,8 +957,8 @@ export const ProxmoxBackupsTable: Component<{
|
|||||||
rows={orphanedCoverageRows()}
|
rows={orphanedCoverageRows()}
|
||||||
hasAnyRows={orphanedTotalCount() > 0}
|
hasAnyRows={orphanedTotalCount() > 0}
|
||||||
emptyIcon={props.emptyIcon}
|
emptyIcon={props.emptyIcon}
|
||||||
emptyTitle="No orphaned backups"
|
emptyTitle="No unmatched backups"
|
||||||
emptyDescription="Backups whose guest no longer exists will appear here."
|
emptyDescription="Backups without an unambiguous live guest match will appear here."
|
||||||
sortKey={coverageSortKey}
|
sortKey={coverageSortKey}
|
||||||
sortDirection={coverageSortDirection}
|
sortDirection={coverageSortDirection}
|
||||||
onSort={handleCoverageSort}
|
onSort={handleCoverageSort}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { createSignal, type JSX } from 'solid-js';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable';
|
import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable';
|
||||||
|
import {
|
||||||
|
BACKUP_IDENTITY_NOW,
|
||||||
|
identityGuest,
|
||||||
|
identityArchive,
|
||||||
|
identityPBS,
|
||||||
|
identitySnapshot,
|
||||||
|
identityTask,
|
||||||
|
} from '../__fixtures__/backupIdentity';
|
||||||
import { buildBackupServerRows } from '../ProxmoxBackupServersTable';
|
import { buildBackupServerRows } from '../ProxmoxBackupServersTable';
|
||||||
import proxmoxBackupServersTableSource from '../ProxmoxBackupServersTable.tsx?raw';
|
import proxmoxBackupServersTableSource from '../ProxmoxBackupServersTable.tsx?raw';
|
||||||
import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw';
|
import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw';
|
||||||
@@ -1142,3 +1150,88 @@ describe('backup-date-evidence connected inventory', () => {
|
|||||||
expect(within(coverageRow()).getByText('Protected')).toBeInTheDocument();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-4
@@ -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).
|
// 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', () => {
|
it('matches prior native node aliases while presenting the current display name', () => {
|
||||||
const model = buildModel({
|
const model = buildModel({
|
||||||
workloads: [
|
workloads: [
|
||||||
@@ -210,7 +210,7 @@ describe('matchWorkloadByHints branches (via vzdump tasks)', () => {
|
|||||||
expect(model.coverageRows.every((row) => row.latestTask === undefined)).toBe(true);
|
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({
|
const model = buildModel({
|
||||||
workloads: [workload({ id: 'vm-440', proxmox: { vmid: 440, node: 'alpha' } })],
|
workloads: [workload({ id: 'vm-440', proxmox: { vmid: 440, node: 'alpha' } })],
|
||||||
tasks: [
|
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', () => {
|
it('falls back to the only candidate when hints are all blank', () => {
|
||||||
|
|||||||
+171
-27
@@ -26,36 +26,63 @@ const owners = (result: ReturnType<typeof model>) =>
|
|||||||
|
|
||||||
describe('backup-identity: native ownership rather than first label match', () => {
|
describe('backup-identity: native ownership rather than first label match', () => {
|
||||||
it.each([false, true])('is independent of inventory order (reversed: %s)', (reversed) => {
|
it.each([false, true])('is independent of inventory order (reversed: %s)', (reversed) => {
|
||||||
const workloads = [identityGuest('west-100', 'west', 'pve-10'), identityGuest('east-100', 'east', 'pve-1')];
|
const workloads = [
|
||||||
|
identityGuest('west-100', 'west', 'pve-10'),
|
||||||
|
identityGuest('east-100', 'east', 'pve-1'),
|
||||||
|
];
|
||||||
if (reversed) workloads.reverse();
|
if (reversed) workloads.reverse();
|
||||||
const result = model({ workloads, archives: [identityArchive()], snapshots: [identitySnapshot()], pbsBackups: [identityPBS()], tasks: [identityTask()] });
|
const result = model({
|
||||||
|
workloads,
|
||||||
|
archives: [identityArchive()],
|
||||||
|
snapshots: [identitySnapshot()],
|
||||||
|
pbsBackups: [identityPBS()],
|
||||||
|
tasks: [identityTask()],
|
||||||
|
});
|
||||||
expect(owners(result)).toEqual(['east-100', 'east-100', 'east-100']);
|
expect(owners(result)).toEqual(['east-100', 'east-100', 'east-100']);
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined();
|
expect(
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
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', () => {
|
it('uses the PVE instance before a reused node name', () => {
|
||||||
const result = model({
|
const result = model({
|
||||||
workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')],
|
workloads: [
|
||||||
archives: [identityArchive()], snapshots: [identitySnapshot()], tasks: [identityTask()],
|
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(owners(result)).toEqual(['east-100', 'east-100']);
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined();
|
expect(
|
||||||
|
result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask,
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps same-instance migrated backups without using the old node to select another cluster', () => {
|
it('keeps same-instance migrated backups without using the old node to select another cluster', () => {
|
||||||
const result = model({
|
const result = model({
|
||||||
workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-2')],
|
workloads: [
|
||||||
archives: [identityArchive()], tasks: [identityTask()],
|
identityGuest('west-100', 'west', 'pve-1'),
|
||||||
|
identityGuest('east-100', 'east', 'pve-2'),
|
||||||
|
],
|
||||||
|
archives: [identityArchive()],
|
||||||
|
tasks: [identityTask()],
|
||||||
});
|
});
|
||||||
expect(owners(result)).toEqual(['east-100']);
|
expect(owners(result)).toEqual(['east-100']);
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
expect(
|
||||||
|
result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id,
|
||||||
|
).toBe('task-100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not let a matching node override a conflicting PVE instance, even with one visible guest', () => {
|
it('does not let a matching node override a conflicting PVE instance, even with one visible guest', () => {
|
||||||
const result = model({
|
const result = model({
|
||||||
workloads: [identityGuest('east-100', 'east', 'pve-1')],
|
workloads: [identityGuest('east-100', 'east', 'pve-1')],
|
||||||
archives: [identityArchive({ instance: 'removed-east' })], snapshots: [identitySnapshot({ instance: 'removed-east' })],
|
archives: [identityArchive({ instance: 'removed-east' })],
|
||||||
|
snapshots: [identitySnapshot({ instance: 'removed-east' })],
|
||||||
tasks: [identityTask({ instance: 'removed-east' })],
|
tasks: [identityTask({ instance: 'removed-east' })],
|
||||||
});
|
});
|
||||||
expect(owners(result)).toEqual([undefined, undefined]);
|
expect(owners(result)).toEqual([undefined, undefined]);
|
||||||
@@ -70,54 +97,160 @@ describe('backup-identity: native ownership rather than first label match', () =
|
|||||||
it('does not accept a partial node match or contradictory singleton when PVE instance is absent', () => {
|
it('does not accept a partial node match or contradictory singleton when PVE instance is absent', () => {
|
||||||
const result = model({
|
const result = model({
|
||||||
workloads: [identityGuest('west-100', '', 'pve-10')],
|
workloads: [identityGuest('west-100', '', 'pve-10')],
|
||||||
archives: [identityArchive({ instance: '' })], snapshots: [identitySnapshot({ instance: '' })], tasks: [identityTask({ instance: '' })],
|
archives: [identityArchive({ instance: '' })],
|
||||||
|
snapshots: [identitySnapshot({ instance: '' })],
|
||||||
|
tasks: [identityTask({ instance: '' })],
|
||||||
});
|
});
|
||||||
expect(owners(result)).toEqual([undefined, undefined]);
|
expect(owners(result)).toEqual([undefined, undefined]);
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId)?.latestTask).toBeUndefined();
|
expect(result.coverageRows.find((row) => row.workload.resourceId)?.latestTask).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts exact native aliases, not mutable display labels', () => {
|
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 workloads = [
|
||||||
const result = model({ workloads, archives: [identityArchive({ instance: '' })], pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] });
|
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(owners(result)).toEqual(['east-100', 'east-100']);
|
||||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
expect(
|
||||||
|
result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id,
|
||||||
|
).toBe('task-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps presentation-only parent labels out of native-node matching', () => {
|
||||||
|
const misleading = { ...identityGuest('west-100', '', ''), parentName: 'pve-1' };
|
||||||
|
const result = model({
|
||||||
|
workloads: [misleading, identityGuest('east-100', '', 'pve-1')],
|
||||||
|
archives: [identityArchive({ instance: '' })],
|
||||||
|
pbsBackups: [identityPBS()],
|
||||||
|
});
|
||||||
|
expect(owners(result)).toEqual(['east-100', 'east-100']);
|
||||||
|
expect(
|
||||||
|
result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.workload.node,
|
||||||
|
).toBe('pve-1');
|
||||||
|
expect(
|
||||||
|
result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.workload
|
||||||
|
.nativeNode,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collide unresolved repository keys on case or delimiter framing', () => {
|
||||||
|
const result = model({
|
||||||
|
pbsBackups: [
|
||||||
|
identityPBS({ id: 'case-a', instance: 'pbs', datastore: 'main' }),
|
||||||
|
identityPBS({ id: 'case-b', instance: 'pbs', datastore: 'Main' }),
|
||||||
|
identityPBS({ id: 'parts-a', instance: 'a:b', datastore: 'c' }),
|
||||||
|
identityPBS({ id: 'parts-b', instance: 'a', datastore: 'b:c' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.coverageRows).toHaveLength(4);
|
||||||
|
expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps source names with spaces or quotes in an IDREF-safe unresolved key', () => {
|
||||||
|
const result = model({
|
||||||
|
pbsBackups: [
|
||||||
|
identityPBS({
|
||||||
|
instance: 'backup server',
|
||||||
|
datastore: 'main "store"',
|
||||||
|
namespace: 'old node',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const key = result.coverageRows[0].key;
|
||||||
|
expect(key).not.toMatch(/[\s"']/);
|
||||||
|
expect(decodeURIComponent(key)).toContain('backup server');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not turn a reused namespace/node or alias into a unique PBS owner', () => {
|
it('does not turn a reused namespace/node or alias into a unique PBS owner', () => {
|
||||||
const result = model({ workloads: [identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }), identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] })], pbsBackups: [identityPBS()] });
|
const result = model({
|
||||||
|
workloads: [
|
||||||
|
identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }),
|
||||||
|
identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] }),
|
||||||
|
],
|
||||||
|
pbsBackups: [identityPBS()],
|
||||||
|
});
|
||||||
expect(owners(result)).toEqual([undefined]);
|
expect(owners(result)).toEqual([undefined]);
|
||||||
expect(result.coverageRows.filter((row) => row.workload.resourceId).every((row) => row.pbsCount === 0)).toBe(true);
|
expect(
|
||||||
|
result.coverageRows
|
||||||
|
.filter((row) => row.workload.resourceId)
|
||||||
|
.every((row) => row.pbsCount === 0),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not use a datastore label as a PVE workload identity', () => {
|
it('does not use a datastore label as a PVE workload identity', () => {
|
||||||
const result = model({ workloads: [identityGuest('west-100', 'west', 'main'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] });
|
const result = model({
|
||||||
|
workloads: [
|
||||||
|
identityGuest('west-100', 'west', 'main'),
|
||||||
|
identityGuest('east-100', 'east', 'pve-1'),
|
||||||
|
],
|
||||||
|
pbsBackups: [identityPBS({ namespace: '' })],
|
||||||
|
});
|
||||||
expect(owners(result)).toEqual([undefined]);
|
expect(owners(result)).toEqual([undefined]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves root-namespace PBS for a unique VMID and exact instance namespace with collisions', () => {
|
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(
|
||||||
expect(owners(model({ workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: ' EAST ' })] }))).toEqual(['east-100']);
|
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', () => {
|
it('retains exact-node matching without instance metadata and the unscoped singleton control', () => {
|
||||||
const workload = identityGuest('east-100', '', 'pve-1');
|
const workload = identityGuest('east-100', '', 'pve-1');
|
||||||
expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '' })] }))).toEqual(['east-100']);
|
expect(
|
||||||
expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '', node: '' })] }))).toEqual(['east-100']);
|
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', () => {
|
it('leaves an ambiguous unknown-type task unattached across VM and LXC identities', () => {
|
||||||
const vm = identityGuest('vm-100', 'east', 'pve-1');
|
const vm = identityGuest('vm-100', 'east', 'pve-1');
|
||||||
const ct = { ...identityGuest('ct-100', 'east', 'pve-1'), type: 'system-container' } as typeof vm;
|
const ct = {
|
||||||
|
...identityGuest('ct-100', 'east', 'pve-1'),
|
||||||
|
type: 'system-container',
|
||||||
|
} as typeof vm;
|
||||||
const result = model({ workloads: [vm, ct], tasks: [identityTask()] });
|
const result = model({ workloads: [vm, ct], tasks: [identityTask()] });
|
||||||
expect(result.coverageRows.every((row) => row.latestTask === undefined)).toBe(true);
|
expect(result.coverageRows.every((row) => row.latestTask === undefined)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves unresolved artifacts but does not pool identically named PBS sources', () => {
|
it('preserves unresolved artifacts but does not pool identically named PBS sources', () => {
|
||||||
const result = model({ pbsBackups: [identityPBS({ id: 'pbs-a', instance: 'pbs-a' }), identityPBS({ id: 'pbs-b', instance: 'pbs-b' })] });
|
const result = model({
|
||||||
|
pbsBackups: [
|
||||||
|
identityPBS({ id: 'pbs-a', instance: 'pbs-a' }),
|
||||||
|
identityPBS({ id: 'pbs-b', instance: 'pbs-b' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
expect(result.recoverableArtifacts).toHaveLength(2);
|
expect(result.recoverableArtifacts).toHaveLength(2);
|
||||||
expect(result.coverageRows).toHaveLength(2);
|
expect(result.coverageRows).toHaveLength(2);
|
||||||
expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(2);
|
expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(2);
|
||||||
expect(result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated')).toBe(true);
|
expect(
|
||||||
|
result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated'),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => {
|
it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => {
|
||||||
@@ -126,8 +259,19 @@ describe('backup-identity: native ownership rather than first label match', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps unresolved PVE instances separate while associating their own typed and untyped tasks', () => {
|
it('keeps unresolved PVE instances separate while associating their own typed and untyped tasks', () => {
|
||||||
const result = model({ archives: [identityArchive({ id: 'file-east' }), identityArchive({ id: 'file-west', instance: 'west' })], tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })] });
|
const result = model({
|
||||||
|
archives: [
|
||||||
|
identityArchive({ id: 'file-east' }),
|
||||||
|
identityArchive({ id: 'file-west', instance: 'west' }),
|
||||||
|
],
|
||||||
|
tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })],
|
||||||
|
});
|
||||||
expect(result.coverageRows).toHaveLength(2);
|
expect(result.coverageRows).toHaveLength(2);
|
||||||
expect(result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort()).toEqual([['east', 'task-100'], ['west', 'task-west']]);
|
expect(
|
||||||
|
result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort(),
|
||||||
|
).toEqual([
|
||||||
|
['east', 'task-100'],
|
||||||
|
['west', 'task-west'],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -442,16 +442,21 @@ describe('proxmoxBackupRecoveryModel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(model.recoverableArtifacts).toHaveLength(3);
|
expect(model.recoverableArtifacts).toHaveLength(3);
|
||||||
expect(model.coverageRows).toHaveLength(1);
|
expect(model.coverageRows).toHaveLength(2);
|
||||||
expect(model.coverageSummary.totalWorkloads).toBe(1);
|
expect(model.coverageSummary.totalWorkloads).toBe(2);
|
||||||
expect(model.coverageRows[0].isOrphaned).toBe(false);
|
// Equal host labels on PVE and PBS are not proof of the same backup target.
|
||||||
expect(model.coverageRows[0].workload.type).toBe('host');
|
expect(model.coverageRows.every((row) => !row.isOrphaned && row.workload.type === 'host')).toBe(
|
||||||
expect(model.coverageRows[0].workload.vmid).toBe('mail-gateway');
|
true,
|
||||||
expect(model.coverageRows[0].workload.label).toBe('Host mail-gateway');
|
);
|
||||||
expect(model.coverageRows[0].posture).toBe('not-evaluated');
|
expect(
|
||||||
expect(getWorkloadRecoveryPostureLabel(model.coverageRows[0].posture)).toBe('Not evaluated');
|
model.coverageRows.every(
|
||||||
expect(model.coverageRows[0].pbsCount).toBe(1);
|
(row) => row.workload.label === 'Host mail-gateway' && row.posture === 'not-evaluated',
|
||||||
expect(model.coverageRows[0].archiveCount).toBe(1);
|
),
|
||||||
|
).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(model.recoverableArtifacts.map((artifact) => artifact.workload.label)).toEqual(
|
||||||
expect.arrayContaining(['LXC backup', 'Host mail-gateway']),
|
expect.arrayContaining(['LXC backup', 'Host mail-gateway']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export interface WorkloadCoverageRow {
|
|||||||
postureRank: number;
|
postureRank: number;
|
||||||
protectionPosture?: ProtectionPosture;
|
protectionPosture?: ProtectionPosture;
|
||||||
// True only when a VM/LXC row exists because a backup/task referenced a VMID
|
// 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.
|
// `backup:` key, but they are first-class backup targets, not orphaned guests.
|
||||||
isOrphaned: boolean;
|
isOrphaned: boolean;
|
||||||
}
|
}
|
||||||
@@ -124,10 +124,7 @@ interface BuildModelInput {
|
|||||||
protectionPosturesResolved?: boolean;
|
protectionPosturesResolved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkloadCandidate extends WorkloadReference {
|
type WorkloadCandidate = WorkloadReference;
|
||||||
nodeKey?: string;
|
|
||||||
instanceKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkloadRowDraft = Omit<WorkloadCoverageRow, 'posture' | 'postureRank' | 'isOrphaned'>;
|
type WorkloadRowDraft = Omit<WorkloadCoverageRow, 'posture' | 'postureRank' | 'isOrphaned'>;
|
||||||
|
|
||||||
@@ -266,7 +263,7 @@ function resourceBackupType(resource: Resource): WorkloadReference['type'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resourceNode(resource: Resource): string | undefined {
|
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 {
|
function resourceNodeDisplayName(resource: Resource): string | undefined {
|
||||||
@@ -316,8 +313,6 @@ function buildCandidateFromResource(resource: Resource): WorkloadCandidate | nul
|
|||||||
nativeNode,
|
nativeNode,
|
||||||
nativeNodeAliases,
|
nativeNodeAliases,
|
||||||
instance,
|
instance,
|
||||||
nodeKey: normalizeKey(nativeNode),
|
|
||||||
instanceKey: normalizeKey(instance),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,18 +320,24 @@ function fallbackWorkload(
|
|||||||
type: WorkloadReference['type'],
|
type: WorkloadReference['type'],
|
||||||
vmid: string,
|
vmid: string,
|
||||||
hints: readonly (string | undefined)[],
|
hints: readonly (string | undefined)[],
|
||||||
|
sourceScope: readonly (string | undefined)[],
|
||||||
|
pveInstance?: string,
|
||||||
): WorkloadReference {
|
): WorkloadReference {
|
||||||
const id = fallbackWorkloadId(type, vmid, hints);
|
const id = fallbackWorkloadId(type, vmid, hints);
|
||||||
const scope = hints.map(normalizeKey).find(Boolean) || 'unknown';
|
// A VMID (or host label) and repository name are not cross-source identity.
|
||||||
const keyScope = type === 'host' && id ? 'host' : scope;
|
// Keep unresolved evidence listed, with a collision-safe complete source key.
|
||||||
|
const scope = encodeURIComponent(
|
||||||
|
JSON.stringify(sourceScope.map((part) => String(part ?? '').trim())),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
key: `backup:${type}:${id || 'unknown'}:${keyScope}`,
|
key: `backup:${type}:${id || 'unknown'}:${scope}`,
|
||||||
type,
|
type,
|
||||||
typeLabel: typeLabel(type),
|
typeLabel: typeLabel(type),
|
||||||
vmid: id,
|
vmid: id,
|
||||||
label: workloadFallbackLabel(type, id),
|
label: workloadFallbackLabel(type, id),
|
||||||
node: type === 'host' ? undefined : hints.find((hint) => !!hint?.trim()),
|
node: type === 'host' ? undefined : hints.find((hint) => !!hint?.trim()),
|
||||||
nativeNode: 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 };
|
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,
|
candidates: WorkloadCandidateIndex,
|
||||||
type: WorkloadReference['type'],
|
type: WorkloadReference['type'],
|
||||||
vmid: string,
|
vmid: string,
|
||||||
hints: readonly (string | undefined)[],
|
instance: string,
|
||||||
|
node: string,
|
||||||
): WorkloadReference {
|
): WorkloadReference {
|
||||||
const typed = candidates.byTypeAndVmid.get(workloadTypeVmidKey(type, vmid)) ?? [];
|
const typed = candidates.byTypeAndVmid.get(workloadTypeVmidKey(type, vmid)) ?? [];
|
||||||
const normalizedHints = hints.map(normalizeKey).filter(Boolean);
|
return (
|
||||||
if (typed.length > 0 && normalizedHints.length > 0) {
|
matchPVEWorkload(typed, instance, node) ??
|
||||||
const exact = typed.find((candidate) =>
|
fallbackWorkload(
|
||||||
normalizedHints.some(
|
type,
|
||||||
(hint) =>
|
vmid,
|
||||||
hint === candidate.nodeKey ||
|
type === 'host' ? [instance, node] : [node, instance],
|
||||||
hint === candidate.instanceKey ||
|
['pve', instance, node],
|
||||||
normalizeKey(candidate.node).includes(hint) ||
|
instance,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchWorkloadByHints<T extends WorkloadReference>(
|
function resolvePBSWorkload(
|
||||||
workloads: readonly T[],
|
candidates: WorkloadCandidateIndex,
|
||||||
hints: readonly (string | undefined)[],
|
backup: PBSBackup,
|
||||||
): T | undefined {
|
): WorkloadReference {
|
||||||
const normalizedHints = hints.map(normalizeKey).filter(Boolean);
|
const type = backupTypeLabel(backup.backupType);
|
||||||
if (workloads.length > 0 && normalizedHints.length > 0) {
|
const typed = candidates.byTypeAndVmid.get(workloadTypeVmidKey(type, backup.vmid)) ?? [];
|
||||||
const exact = workloads.find((workload) =>
|
// PBS instance/datastore identify a repository, not a PVE connection. A
|
||||||
normalizedHints.some(
|
// namespace can disambiguate a repeated VMID only with one exact native
|
||||||
(hint) =>
|
// node/alias or connection match. A display name or substring proves none.
|
||||||
hint === normalizeKey(workload.node) ||
|
const namespace = normalizeKey(backup.namespace);
|
||||||
hint === normalizeKey(workload.nativeNode) ||
|
const matched = namespace
|
||||||
hint === normalizeKey(workload.instance) ||
|
? uniqueWorkload(
|
||||||
normalizeKey(workload.node).includes(hint) ||
|
typed.filter(
|
||||||
normalizeKey(workload.nativeNode).includes(hint) ||
|
(workload) =>
|
||||||
workload.nativeNodeAliases?.some((alias) => normalizeKey(alias).includes(hint)) ||
|
matchesNativeNode(workload, backup.namespace) ||
|
||||||
normalizeKey(workload.instance).includes(hint),
|
normalizeKey(workload.instance) === namespace,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
if (exact) return exact;
|
: undefined;
|
||||||
}
|
return (
|
||||||
if (workloads.length === 1) return workloads[0];
|
matched ??
|
||||||
return undefined;
|
uniqueWorkload(typed) ??
|
||||||
|
fallbackWorkload(
|
||||||
|
type,
|
||||||
|
backup.vmid,
|
||||||
|
[backup.namespace, backup.datastore],
|
||||||
|
['pbs', backup.instance, backup.datastore, backup.namespace],
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskWorkload(
|
function resolveTaskWorkload(
|
||||||
candidates: WorkloadCandidateIndex,
|
candidates: WorkloadCandidateIndex,
|
||||||
workloadsByVmid: ReadonlyMap<string, readonly WorkloadReference[]>,
|
pveWorkloadsByVmid: ReadonlyMap<string, readonly WorkloadReference[]>,
|
||||||
task: BackupTask,
|
task: BackupTask,
|
||||||
): WorkloadReference | undefined {
|
): WorkloadReference | undefined {
|
||||||
const vmid = String(task.vmid);
|
const vmid = String(task.vmid);
|
||||||
if (!vmid || isZeroWorkloadId(vmid)) return undefined;
|
if (!vmid || isZeroWorkloadId(vmid)) return undefined;
|
||||||
|
|
||||||
const explicitType = backupTypeLabel(task.type);
|
const explicitType = backupTypeLabel(task.type);
|
||||||
const hints = [task.instance, task.node];
|
if (explicitType !== 'unknown')
|
||||||
if (explicitType !== 'unknown') return resolveWorkload(candidates, explicitType, vmid, hints);
|
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;
|
if (candidate) return candidate;
|
||||||
|
// Only PVE-backed orphan rows share this source scope. A PBS namespace that
|
||||||
return matchWorkloadByHints(workloadsByVmid.get(vmid) ?? [], hints);
|
// 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 {
|
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);
|
.filter((candidate): candidate is WorkloadCandidate => candidate !== null);
|
||||||
const candidateIndex = buildWorkloadCandidateIndex(candidates);
|
const candidateIndex = buildWorkloadCandidateIndex(candidates);
|
||||||
const rows = new Map<string, WorkloadRowDraft>();
|
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);
|
const existing = rows.get(workload.key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const row: WorkloadRowDraft = {
|
const row: WorkloadRowDraft = {
|
||||||
@@ -563,13 +610,15 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
snapshotCount: 0,
|
snapshotCount: 0,
|
||||||
};
|
};
|
||||||
rows.set(workload.key, row);
|
rows.set(workload.key, row);
|
||||||
const matchingVmid = workloadsByVmid.get(workload.vmid);
|
if (pveSource) {
|
||||||
if (matchingVmid) matchingVmid.push(workload);
|
const matchingVmid = pveWorkloadsByVmid.get(workload.vmid);
|
||||||
else workloadsByVmid.set(workload.vmid, [workload]);
|
if (matchingVmid) matchingVmid.push(workload);
|
||||||
|
else pveWorkloadsByVmid.set(workload.vmid, [workload]);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const candidate of candidates) ensureRow(candidate);
|
for (const candidate of candidates) ensureRow(candidate, true);
|
||||||
|
|
||||||
const artifacts: RecoverableArtifact[] = [];
|
const artifacts: RecoverableArtifact[] = [];
|
||||||
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
|
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
|
||||||
@@ -578,7 +627,7 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
const addArtifact = (artifact: RecoverableArtifact) => {
|
const addArtifact = (artifact: RecoverableArtifact) => {
|
||||||
artifacts.push(artifact);
|
artifacts.push(artifact);
|
||||||
if (!isCoverageWorkload(artifact.workload)) return;
|
if (!isCoverageWorkload(artifact.workload)) return;
|
||||||
const row = ensureRow(artifact.workload);
|
const row = ensureRow(artifact.workload, artifact.sourceKind !== 'pbs');
|
||||||
row.artifacts.push(artifact);
|
row.artifacts.push(artifact);
|
||||||
if (artifact.sourceKind === 'pbs') row.pbsCount += 1;
|
if (artifact.sourceKind === 'pbs') row.pbsCount += 1;
|
||||||
else if (artifact.sourceKind === 'archive') row.archiveCount += 1;
|
else if (artifact.sourceKind === 'archive') row.archiveCount += 1;
|
||||||
@@ -586,11 +635,7 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const backup of input.pbsBackups) {
|
for (const backup of input.pbsBackups) {
|
||||||
const type = backupTypeLabel(backup.backupType);
|
const workload = resolvePBSWorkload(candidateIndex, backup);
|
||||||
const workload = resolveWorkload(candidateIndex, type, backup.vmid, [
|
|
||||||
backup.namespace,
|
|
||||||
backup.datastore,
|
|
||||||
]);
|
|
||||||
const parsedMs = parseTimestampMs(backup.backupTime);
|
const parsedMs = parseTimestampMs(backup.backupTime);
|
||||||
const createdMs =
|
const createdMs =
|
||||||
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
||||||
@@ -624,10 +669,13 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
|
|
||||||
for (const archive of input.archives) {
|
for (const archive of input.archives) {
|
||||||
const type = backupTypeLabel(archive.type);
|
const type = backupTypeLabel(archive.type);
|
||||||
const workload = resolveWorkload(candidateIndex, type, String(archive.vmid), [
|
const workload = resolvePVEWorkload(
|
||||||
|
candidateIndex,
|
||||||
|
type,
|
||||||
|
String(archive.vmid),
|
||||||
archive.instance,
|
archive.instance,
|
||||||
archive.node,
|
archive.node,
|
||||||
]);
|
);
|
||||||
const parsedMs = parseTimestampMs(archive.time);
|
const parsedMs = parseTimestampMs(archive.time);
|
||||||
const createdMs =
|
const createdMs =
|
||||||
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
||||||
@@ -657,10 +705,13 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
|
|
||||||
for (const snapshot of input.snapshots) {
|
for (const snapshot of input.snapshots) {
|
||||||
const type = backupTypeLabel(snapshot.type);
|
const type = backupTypeLabel(snapshot.type);
|
||||||
const workload = resolveWorkload(candidateIndex, type, String(snapshot.vmid), [
|
const workload = resolvePVEWorkload(
|
||||||
|
candidateIndex,
|
||||||
|
type,
|
||||||
|
String(snapshot.vmid),
|
||||||
snapshot.instance,
|
snapshot.instance,
|
||||||
snapshot.node,
|
snapshot.node,
|
||||||
]);
|
);
|
||||||
const parsedMs = parseTimestampMs(snapshot.time);
|
const parsedMs = parseTimestampMs(snapshot.time);
|
||||||
const createdMs =
|
const createdMs =
|
||||||
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
getRecoveryAgeBand(parsedMs, input.nowMs) === 'unknown' ? undefined : parsedMs;
|
||||||
@@ -682,9 +733,9 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const task of input.tasks) {
|
for (const task of input.tasks) {
|
||||||
const workload = resolveTaskWorkload(candidateIndex, workloadsByVmid, task);
|
const workload = resolveTaskWorkload(candidateIndex, pveWorkloadsByVmid, task);
|
||||||
if (!workload) continue;
|
if (!workload) continue;
|
||||||
const row = ensureRow(workload);
|
const row = ensureRow(workload, true);
|
||||||
const candidateTask: CoverageTask = {
|
const candidateTask: CoverageTask = {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
|||||||
Reference in New Issue
Block a user