Integrate reviewed PBS machine identity boundaries

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-30 03:24:49 +01:00
7 changed files with 743 additions and 78 deletions
@@ -0,0 +1,201 @@
// Offline verification of the production table, drawer and History requests.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules/pbs-identity-boundary-proof');
fs.mkdirSync(artifacts, { recursive: true });
fs.chmodSync(artifacts, 0o755);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
server: { host: '127.0.0.1', port: 5211, strictPort: true },
});
let browser;
const results = [];
try {
await server.listen();
browser = await chromium.launch({
headless: true,
channel: 'chromium',
args: ['--no-sandbox'],
});
for (const width of [1365, 390]) {
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
});
page.setDefaultTimeout(20_000);
const pageErrors = [];
const requests = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5211') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const type = url.searchParams.get('resourceType');
const id = url.searchParams.get('resourceId');
requests.push({ method: route.request().method(), target: `${type}:${id}` });
assert.ok(!id.startsWith('unrelated-'), `wrong-host History requested: ${id}`);
return route.fulfill({
json: {
resourceType: type,
resourceId: id,
range: url.searchParams.get('range'),
start: Date.now() - 3_600_000,
end: Date.now(),
source: 'store',
// PBS API supplies only service CPU/memory, not invented host series.
metrics: Object.fromEntries(
(id.startsWith('pbs-')
? ['cpu', 'memory']
: ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite']
).map((metric) => [
metric,
[30, 20, 10].map((minutes, index) => ({
timestamp: Date.now() - minutes * 60_000,
value: 10 + index * 5,
min: 10 + index * 5,
max: 10 + index * 5,
})),
]),
),
},
});
}
if (url.pathname === '/api/license/runtime-capabilities') {
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community', label: 'Pulse Community runtime' },
blocked_capabilities: [],
},
});
}
return route.fulfill({ json: { data: [], enabled: false } });
});
await page.goto('http://127.0.0.1:5211/browser-tests/pbs-identity-boundary.html', {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
const observations = [];
const inspect = async (suffix, expected, state, open = true) => {
const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`);
if (open) {
const toggle = page.getByRole('button', {
name: `Expand details for backup-connection-${suffix}`,
exact: true,
});
if (width === 1365) {
await toggle.focus();
await page.keyboard.press('Enter');
} else await page.locator(`td[title="backup-connection-${suffix} · tank"]`).click();
}
await detail.waitFor();
const overview = detail.getByRole('tab', { name: 'Overview', exact: true });
await overview.click();
const identity = detail
.locator('[data-testid="resource-identity-section"] tr')
.filter({ hasText: 'Metrics Target' });
await identity.getByText(expected, { exact: true }).waitFor();
assert.ok(!(await detail.innerText()).includes('/WRONG-HOST'), 'wrong-host disks rendered');
await detail.getByRole('tab', { name: 'History', exact: true }).click();
await detail.locator('[data-testid="guest-history-plot"] path').first().waitFor();
assert.equal(
await detail
.getByRole('tab', { name: 'History', exact: true })
.getAttribute('aria-selected'),
'true',
);
assert.ok(
requests.some((request) => request.target === expected),
`missing History read for ${expected}`,
);
const targetStart = requests.length;
await detail.getByTestId('guest-history-range-control').selectOption('6h');
// Force a fresh read even when the open drawer already has 6h selected.
await detail.getByTestId('guest-history-range-control').selectOption('1h');
await page.waitForFunction(() => !document.body.innerText.includes('Loading history'));
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
inner: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
observations.push({
state,
suffix,
expected,
dimensions,
newTargets: requests.slice(targetStart),
});
console.log(JSON.stringify({ width, state, expected }));
};
for (const suffix of ['one', 'two', 'three'])
await inspect(suffix, `agent:pbs-${suffix}`, 'no-link-label-collisions');
await page.screenshot({
path: path.join(artifacts, `unlinked-${width}.png`),
fullPage: true,
});
await page.getByRole('button', { name: 'Corroborate links', exact: true }).click();
await inspect('three', 'agent:agent-three', 'explicit-link', false);
await inspect('one', 'vm:vm-one', 'same-Agent-guest-deduplication');
await inspect('two', 'vm:vm-two', 'second-guest');
await inspect('three', 'agent:agent-three', 'standalone-host');
await page.screenshot({ path: path.join(artifacts, `linked-${width}.png`), fullPage: true });
await page.getByRole('button', { name: 'Omit linked host rows', exact: true }).click();
await inspect('three', 'agent:agent-three', 'unchanged-link-host-omission', false);
await page.getByRole('button', { name: 'Restore linked host rows', exact: true }).click();
await inspect('three', 'agent:agent-three', 'restored-host', false);
await page.getByRole('button', { name: 'Withdraw third link', exact: true }).click();
await inspect('three', 'agent:pbs-three', 'withdrawn-link-with-hosts-still-present', false);
assert.ok(
!(
await page.locator('[data-inline-platform-resource-detail-for="pbs-three"]').innerText()
).includes('/real-three'),
'withdrawn host disks remained visible',
);
await page.screenshot({
path: path.join(artifacts, `withdrawn-${width}.png`),
fullPage: true,
});
await page.getByRole('button', { name: 'Report exact node hostname', exact: true }).click();
await inspect('one', 'vm:vm-one', 'exact-reported-machine-hostname');
await inspect('three', 'agent:pbs-three', 'remaining-uncorroborated-host');
assert.ok(requests.length > 0);
assert.ok(requests.every((request) => request.method === 'GET'));
assert.deepEqual(pageErrors, []);
results.push({ width, observations, requests, pageErrors });
await page.close();
}
const file = 'src/features/proxmox/ProxmoxBackupServersTable.tsx';
const result = {
result: 'passed',
browser: browser.version(),
playwright: '1.56.1',
content_sha256: {
[`frontend-modern/${file}`]: createHash('sha256')
.update(fs.readFileSync(path.join(root, file)))
.digest('hex'),
},
results,
};
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
for (const name of fs.readdirSync(artifacts)) fs.chmodSync(path.join(artifacts, name), 0o644);
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PBS identity boundary verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/pbs-identity-boundary.tsx"></script>
</body>
</html>
@@ -0,0 +1,116 @@
// Synthetic bounded Backups/Overview resources, not installed host evidence.
import { createMemo, createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const seen = Date.now();
const suffixes = ['one', 'two', 'three'];
const services = suffixes.map((suffix, index): Resource => ({
id: `pbs-${suffix}`,
type: 'pbs',
name: `backup-connection-${suffix}`,
displayName: `backup-connection-${suffix}`,
platformId: `pbs-${suffix}`,
platformType: 'proxmox-pbs',
sourceType: 'api',
sources: ['pbs'],
status: 'online',
lastSeen: seen,
cpu: { current: 10 + index },
memory: { current: 40 },
metricsTarget: { resourceType: 'agent', resourceId: `pbs-${suffix}` },
canonicalIdentity: { hostname: `10.2.0.${13 + index}` },
pbs: {
instanceId: `pbs-${suffix}`,
hostname: `10.2.0.${13 + index}`,
connectionHealth: 'healthy',
version: '3.2.1',
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
},
}));
const hosts = suffixes.map((suffix): Resource => ({
id: `agent-${suffix}`,
type: 'agent',
name: `Host label ${suffix}`,
platformType: 'proxmox-pbs',
sourceType: 'agent',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: seen,
agent: {
agentId: `agent-${suffix}`,
hostname: `host-${suffix}.example`,
disks: [{ mountpoint: `/real-${suffix}`, total: 1000, used: 250, free: 750, usage: 25 }],
},
metricsTarget: { resourceType: 'agent', resourceId: `agent-${suffix}` },
}));
const guests = hosts.slice(0, 2).map((host, index): Resource => ({
...host,
id: `vm-${suffixes[index]}`,
type: 'vm',
sources: ['proxmox', 'agent'],
metricsTarget: { resourceType: 'vm', resourceId: `vm-${suffixes[index]}` },
}));
const unrelated = Array.from({ length: 6 }, (_, index): Resource => ({
...hosts[index % 3],
id: `unrelated-${index}`,
type: index < 3 ? 'vm' : 'agent',
name: `backup-connection-${suffixes[index % 3]}`,
displayName: `backup-connection-${suffixes[index % 3]}`,
identity: { ips: [`10.9.0.${13 + index}`] },
agent: {
agentId: `unrelated-${index}`,
hostname: `backup-connection-${suffixes[index % 3]}`,
disks: [{ mountpoint: '/WRONG-HOST', total: 1000, used: 999, free: 1, usage: 99.9 }],
},
metricsTarget: { resourceType: 'vm', resourceId: `unrelated-${index}` },
}));
const Fixture = () => {
const [mode, setMode] = createSignal('unlinked');
const servers = createMemo(() => [
...services.map((server, index): Resource => ({
...server,
pbs: {
...server.pbs!,
linkedAgentId:
['linked', 'omitted', 'withdrawn'].includes(mode()) &&
!(mode() === 'withdrawn' && index === 2)
? `agent-${suffixes[index]}`
: undefined,
nodeName: mode() === 'node' && index === 0 ? 'host-one.example' : undefined,
},
})),
...(mode() === 'omitted' ? [] : [...hosts, ...guests]),
...unrelated,
]);
return (
<main class="p-4 space-y-3">
<h1>PBS identity boundary verification</h1>
<div class="flex flex-wrap gap-2">
<button type="button" onClick={() => setMode('linked')}>
Corroborate links
</button>
<button type="button" onClick={() => setMode('omitted')}>
Omit linked host rows
</button>
<button type="button" onClick={() => setMode('linked')}>
Restore linked host rows
</button>
<button type="button" onClick={() => setMode('withdrawn')}>
Withdraw third link
</button>
<button type="button" onClick={() => setMode('node')}>
Report exact node hostname
</button>
</div>
<output aria-label="Evidence state">{mode()}</output>
<ProxmoxBackupServersTable servers={servers()} />
</main>
);
};
render(() => <Fixture />, document.getElementById('root') as HTMLElement);
+69 -18
View File
@@ -1,16 +1,16 @@
{
"version": 1,
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
"verified_at": "2026-09-29T23:04:22Z",
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
"verified_at": "2026-09-30T01:40:13Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36"
},
"routes": [
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
"/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)"
],
"viewports": [
{
@@ -23,26 +23,77 @@
}
],
"states": [
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
"host rows omitted in a transient refresh while the backend link is unchanged",
"third backend link withdrawn while the Agent row remains omitted"
"three unlinked PBS services alongside six label/IP-colliding Agents",
"explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication",
"unchanged links with host rows omitted and restored",
"withdrawn third link with real and colliding host rows still present",
"fresh exact PBS nodeName to Agent hostname match with mismatched display labels"
],
"interactions": [
"open all three PBS drawers and inspect their Metrics Target identity",
"omit host rows and confirm targets stay on the correlated hosts",
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
"desktop keyboard disclosure and phone row activation for all three PBS drawers",
"inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges",
"join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal",
"24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow"
],
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png"
],
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series.",
"notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure.",
"prior_verifications": [
{
"version": 1,
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
"verified_at": "2026-09-29T23:04:22Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
},
"routes": [
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
"host rows omitted in a transient refresh while the backend link is unchanged",
"third backend link withdrawn while the Agent row remains omitted"
],
"interactions": [
"open all three PBS drawers and inspect their Metrics Target identity",
"omit host rows and confirm targets stay on the correlated hosts",
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
],
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
],
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series."
},
{
"version": 1,
"base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3",
@@ -1,4 +1,5 @@
import { For, Show, createMemo, type Accessor, type JSX } from 'solid-js';
import { unwrap } from 'solid-js/store';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -23,7 +24,7 @@ import {
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { PBSBackup } from '@/types/api';
import type { Resource, ResourcePBSDatastore } from '@/types/resource';
import { getNormalizedIdentityLookupVariants } from '@/utils/resourceIdentity';
import { getPlatformAgentRecord } from '@/utils/agentResources';
import type { StatusIndicatorVariant } from '@/utils/status';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
@@ -103,27 +104,19 @@ function usageToneClass(pct: number | undefined): string {
return 'text-base-content';
}
const identityValues = (resource: Resource): Array<string | undefined> => [
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.platformId,
resource.identity?.hostname,
...(resource.identity?.ips ?? []),
resource.agent?.hostname,
resource.pbs?.hostname,
// The node hostname PBS reports about itself is machine identity. The
// connection may be configured by an IP or DNS alias the agent never
// reports, so without this the host correlation can drop out between
// snapshots and the drawer falls back to the service history target
// (#1723).
resource.pbs?.nodeName,
resource.pbs?.instanceId,
resource.platformId,
resource.name,
resource.displayName,
];
// Only the hostname PBS reports about itself can corroborate a host without
// a backend link. Connection labels, instance IDs, canonical presentation
// fields and configured endpoints are not machine identity. In particular,
// the shared dotted-token helper would make unrelated IPs/FQDNs collide.
const machineHostname = (value: unknown): string =>
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : '';
const identityTokens = (resource: Resource): Set<string> =>
new Set(identityValues(resource).flatMap((value) => getNormalizedIdentityLookupVariants(value)));
const correlationEvidenceKey = (server: Resource): string | undefined => {
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
if (linkedAgentId) return `agent:${linkedAgentId}`;
const nodeName = machineHostname(server.pbs?.nodeName);
return nodeName ? `node:${nodeName}` : undefined;
};
const stringValues = (...candidates: unknown[]): string[] =>
candidates.flatMap((candidate) =>
@@ -156,14 +149,32 @@ const isHostWithAgent = (candidate: Resource): boolean =>
(candidate.type === 'agent' || isGuestWithAgent(candidate)) &&
Boolean(candidate.agent ?? candidate.platformData?.agent);
// Host telemetry can be merged into a PVE guest rather than a standalone
// agent. Keep the unique-identity check and require an actual agent facet.
const isCorrelationCandidate = (serverTokens: Set<string>, candidate: Resource): boolean => {
if (!isHostWithAgent(candidate)) return false;
for (const token of identityTokens(candidate)) {
if (serverTokens.has(token)) return true;
const matchingAgentHosts = (server: Resource, candidates: readonly Resource[]): Resource[] => {
// Endpoint/interface and provider guest links belong to the backend. An
// explicit link also vetoes same-name Agents with a different identity.
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
if (linkedAgentId) {
return candidates.filter(
(candidate) => isHostWithAgent(candidate) && correlatedAgentKey(candidate) === linkedAgentId,
);
}
return false;
const nodeName = machineHostname(server.pbs?.nodeName);
if (!nodeName) return [];
return candidates.filter((candidate) => {
if (!isHostWithAgent(candidate) || !correlatedAgentKey(candidate)) return false;
const agent = candidate.agent ?? getPlatformAgentRecord(candidate);
// Do not let stale host observations undo a withdrawn backend link.
const reportSeen =
typeof agent?.lastReportAt === 'string' ? Date.parse(agent.lastReportAt) : candidate.lastSeen;
const delta = Math.abs(server.lastSeen - reportSeen);
return (
agent?.stale !== true &&
server.lastSeen > 0 &&
reportSeen > 0 &&
delta <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS &&
machineHostname(agent?.hostname) === nodeName
);
});
};
const preferredCorrelatedHost = (matches: readonly Resource[]): Resource | undefined =>
@@ -175,22 +186,7 @@ const uniquelyCorrelatedAgent = (
server: Resource,
candidates: readonly Resource[],
): Resource | undefined => {
// The backend can corroborate a PBS connection with a unique agent from
// its reported interface IP even when the PBS token cannot read nodeName
// and the configured connection name differs from the PVE/agent hostname.
// An explicit link also vetoes same-name agents with a different ID.
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
if (linkedAgentId) {
return preferredCorrelatedHost(
candidates.filter(
(candidate) =>
isHostWithAgent(candidate) && correlatedAgentKey(candidate) === linkedAgentId,
),
);
}
const serverTokens = identityTokens(server);
if (serverTokens.size === 0) return undefined;
const matches = candidates.filter((candidate) => isCorrelationCandidate(serverTokens, candidate));
const matches = matchingAgentHosts(server, candidates);
if (matches.length === 0) return undefined;
if (matches.length === 1) return matches[0];
@@ -221,11 +217,8 @@ const uniquelyCorrelatedAgent = (
// distinction matters for correlation retention: a snapshot that simply omits
// the host row is a transient refresh gap, while an ambiguous snapshot is a
// deliberate decline that must not be papered over with a remembered guess.
const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource[]): boolean => {
const serverTokens = identityTokens(server);
if (serverTokens.size === 0) return false;
return candidates.some((candidate) => isCorrelationCandidate(serverTokens, candidate));
};
const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource[]): boolean =>
matchingAgentHosts(server, candidates).length > 0;
// A live refresh can briefly omit the correlated host row (for example while a
// realtime snapshot replaces the merged estate), which used to flip the Backups
@@ -236,7 +229,7 @@ const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource
// indefinitely. A host that is present but ambiguous still declines.
interface RetainedPbsCorrelation {
agent: Resource;
linkedAgentId?: string;
evidenceKey?: string;
}
export type PbsCorrelationRetention = Map<string, RetainedPbsCorrelation>;
@@ -306,7 +299,7 @@ export function buildBackupServerRows(
if (agent) {
retention?.set(server.id, {
agent,
linkedAgentId: server.pbs?.linkedAgentId?.trim() || undefined,
evidenceKey: correlationEvidenceKey(server),
});
return mergePBSAgentPresentation(server, agent);
}
@@ -315,8 +308,7 @@ export function buildBackupServerRows(
// A changed or withdrawn backend link is evidence that the old host
// correlation is no longer trusted. A transiently missing row is not
// enough to keep advertising that host's History after revocation.
const linkedAgentId = server.pbs?.linkedAgentId?.trim() || undefined;
if (linkedAgentId !== retained.linkedAgentId) {
if (correlationEvidenceKey(server) !== retained.evidenceKey) {
retention?.delete(server.id);
return server;
}
@@ -325,7 +317,9 @@ export function buildBackupServerRows(
if (fresh && !hasCorrelationCandidate(server, servers)) {
return mergePBSAgentPresentation(server, retained.agent);
}
if (!fresh) retention?.delete(server.id);
// An ambiguous match revokes the remembered choice too; a later
// omission must not resurrect an identity we already declined.
retention?.delete(server.id);
}
return server;
})
@@ -343,7 +337,6 @@ export function buildBackupServerRows(
.sort((left, right) => left.name.localeCompare(right.name));
const memoryTotal = server.memory?.total ?? 0;
const host = {
resource: server,
serverName: server.name,
online: serverIsOnline(server),
connectionLabel: connectionLabel(server),
@@ -359,14 +352,20 @@ export function buildBackupServerRows(
memoryTotal: memoryTotal > 0 ? memoryTotal : undefined,
uptimeSeconds: server.uptime ?? server.pbs?.uptimeSeconds,
};
// Each keyed row has a reconciled Solid store. Give it its own JSON DTO
// snapshot: otherwise joining an Agent mutates the source's service
// metricsTarget (or another datastore row), defeating later revocation.
// Unwrap reactive source DTOs before cloning; never clone their proxies.
const resourceSnapshot = () => structuredClone(unwrap(server));
if (datastores.length === 0) {
rows.push({ key: server.id, ...host, backupCount: 0 });
rows.push({ key: server.id, ...host, resource: resourceSnapshot(), backupCount: 0 });
continue;
}
for (const datastore of datastores) {
rows.push({
key: `${server.id}:${datastore.name}`,
...host,
resource: resourceSnapshot(),
// Row stores reconcile independently from the nested PBS snapshot. Do
// not alias its datastore object: a reordered snapshot reconciles that
// array by position and would overwrite this row's datastore identity.
@@ -46,6 +46,7 @@ const makePbsResource = (): Resource =>
memory: { current: 40, total: 8_000, used: 3_200, free: 4_800 },
pbs: {
instanceId: 'pbs-main',
nodeName: 'pbs-main.local',
version: '3.2.1',
connectionHealth: 'healthy',
datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600, usagePercent: 40 }],
@@ -187,6 +188,70 @@ describe('ProxmoxBackupServersTable details', () => {
);
});
it('updates an open drawer to the service target when the link is withdrawn beside a colliding guest', async () => {
const pbs = makePbsResource();
pbs.agent = undefined;
pbs.platformData = { sources: ['pbs'] };
pbs.pbs = { ...pbs.pbs!, nodeName: undefined, linkedAgentId: 'agent-pbs-1' };
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
const host = {
...pbs,
id: 'agent-pbs-1',
type: 'agent',
pbs: undefined,
agent: { agentId: 'agent-pbs-1', hostname: 'real-host' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' },
} as Resource;
const unrelated = {
...host,
id: 'vm-other',
type: 'vm',
name: pbs.name,
agent: { agentId: 'agent-other', hostname: pbs.name },
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
} as Resource;
const [servers, setServers] = createSignal([pbs, host, unrelated]);
render(() => <ProxmoxBackupServersTable servers={servers()} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
const detail = screen.getByTestId('pbs-resource-detail');
expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1');
setServers([{ ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: undefined } }, host, unrelated]);
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main'));
expect(detail).not.toHaveAttribute('data-agent-id');
expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1);
});
it('does not mutate reusable service/Agent snapshots while joining and withdrawing host History', async () => {
const pbs = makePbsResource();
pbs.pbs = { ...pbs.pbs!, nodeName: undefined };
pbs.platformData = { sources: ['pbs'] };
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
const host = {
...pbs,
id: 'agent-pbs-1',
type: 'agent',
pbs: undefined,
agent: { agentId: 'agent-pbs-1', hostname: 'real-host' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' },
} as Resource;
const original = JSON.stringify([pbs, host]);
const [servers, setServers] = createSignal([pbs, host]);
render(() => <ProxmoxBackupServersTable servers={servers()} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
const detail = screen.getByTestId('pbs-resource-detail');
expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main');
setServers([{ ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: 'agent-pbs-1' } }, host]);
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1'));
expect(JSON.stringify([pbs, host])).toBe(original);
setServers([pbs, host]);
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main'));
expect(detail).not.toHaveAttribute('data-agent-id');
expect(JSON.stringify([pbs, host])).toBe(original);
expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1);
});
it.each(['agent', 'vm'] as const)(
'does not guess when an agent and %s share the PBS hostname',
(type) => {
@@ -283,11 +348,22 @@ describe('ProxmoxBackupServersTable details', () => {
pbs.name = 'proxback';
pbs.displayName = 'proxback';
pbs.platformId = 'pbs-1';
pbs.pbs = { ...pbs.pbs!, instanceId: 'proxback', hostname: 'proxback-vm' };
// The reported node, not the configured endpoint, corroborates this host.
pbs.pbs = {
...pbs.pbs!,
instanceId: 'proxback',
hostname: 'proxback-vm',
nodeName: 'proxback-vm',
};
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-1' };
pbs.platformData = {
sources: ['pbs'],
pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 },
pbs: {
instanceId: 'proxback',
hostname: 'proxback-vm',
nodeName: 'proxback-vm',
datastoreCount: 1,
},
};
const sharedAgent = { agentId: 'agent-proxback', hostname: 'proxback-vm' };
const guest = {
@@ -347,6 +423,7 @@ describe('ProxmoxBackupServersTable details', () => {
pbs.pbs = {
instanceId: 'proxback',
hostname: 'proxback-vm',
linkedAgentId: 'agent-proxback',
version: '3.2.1',
connectionHealth: 'healthy',
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
@@ -121,6 +121,7 @@ const makeCorrelatablePbs = (lastSeen = 1_700_000_000_000): Resource =>
pbs: {
instanceId: 'proxback',
hostname: 'proxback-vm',
nodeName: 'proxback-vm',
connectionHealth: 'healthy',
datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600 }],
},
@@ -140,11 +141,215 @@ const makeCorrelatedAgent = (overrides: Partial<Resource> = {}): Resource =>
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: 1_700_000_000_000,
agent: { agentId: 'agent-proxback', hostname: 'proxback' },
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' },
...overrides,
}) as Resource;
describe('buildBackupServerRows PBS machine-identity boundary', () => {
it.each(['name', 'displayName', 'platformId', 'instanceId', 'canonicalIdentity', 'identity'])(
'does not import an unrelated guest History or host disks from a PBS %s collision',
(field) => {
const pbs = makeCorrelatablePbs();
pbs.name = 'backup-connection';
pbs.displayName = 'Backup connection';
pbs.platformId = 'pbs-service';
pbs.pbs = {
...pbs.pbs!,
instanceId: 'pbs-service',
hostname: '10.2.0.13',
nodeName: undefined,
};
if (field === 'instanceId') pbs.pbs.instanceId = 'other-host';
else if (field === 'canonicalIdentity') {
pbs.canonicalIdentity = { hostname: 'other-host', platformId: 'other-host' };
} else if (field === 'identity')
pbs.identity = { hostname: 'other-host', ips: ['10.9.0.13'] };
else pbs[field as 'name' | 'displayName' | 'platformId'] = 'other-host';
const unrelated = makeCorrelatedAgent({
id: 'vm-other',
type: 'vm',
name: 'other-host',
agent: {
agentId: 'agent-other',
hostname: 'other-host',
disks: [{ mountpoint: '/wrong-host' }],
},
disk: { current: 99 },
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
});
const row = buildBackupServerRows([pbs, unrelated])[0];
expect(row.resource.metricsTarget).toEqual(pbs.metricsTarget);
expect(row.resource.agent).toBeUndefined();
expect(row.resource.disk).toBeUndefined();
expect(row.resource.sourceType).toBe('api');
},
);
it('does not correlate different endpoint IPs through their first dotted token', () => {
const pbs = makeCorrelatablePbs();
pbs.name = 'backup-connection';
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', nodeName: undefined };
const unrelated = makeCorrelatedAgent({
name: 'other-host',
agent: { agentId: 'agent-other', hostname: 'other-host' },
identity: { ips: ['10.9.0.13'] },
});
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it('does not use a guessed endpoint-to-Agent match without backend corroboration', () => {
const pbs = makeCorrelatablePbs();
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', nodeName: undefined };
const unrelated = makeCorrelatedAgent({ identity: { ips: ['10.2.0.13'] } });
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it('does not let a present label-colliding guest undo backend link withdrawal', () => {
const retention = createPbsCorrelationRetention();
const pbs = makeCorrelatablePbs();
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', linkedAgentId: 'agent-proxback' };
buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention);
const withdrawn = { ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: undefined } };
const unrelated = makeCorrelatedAgent({
id: 'vm-other',
type: 'vm',
agent: { agentId: 'agent-other', hostname: pbs.name },
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
disk: { current: 99 },
});
for (let refresh = 0; refresh < 2; refresh++) {
const row = buildBackupServerRows([withdrawn, unrelated], [], retention);
expect(row[0].resource.metricsTarget).toEqual(pbs.metricsTarget);
expect(row[0].resource.agent).toBeUndefined();
expect(row[0].resource.disk).toBeUndefined();
expect(retention.size).toBe(0);
}
});
it('does not match a reported PBS node to a guest display name instead of its Agent hostname', () => {
const pbs = makeCorrelatablePbs();
pbs.pbs = { ...pbs.pbs!, nodeName: 'real-pbs.example' };
const unrelated = makeCorrelatedAgent({
name: 'real-pbs.example',
agent: { agentId: 'agent-other', hostname: 'other-host.example' },
});
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it('does not treat different qualified machine names as the same host', () => {
const pbs = makeCorrelatablePbs();
pbs.pbs = { ...pbs.pbs!, nodeName: 'backup.production.example' };
const unrelated = makeCorrelatedAgent({
agent: { agentId: 'agent-other', hostname: 'backup.test.example' },
});
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it.each(['direct', 'platform'])(
'preserves a fresh reported node-to-%s-Agent hostname match, ignoring display labels',
(facet) => {
const pbs = makeCorrelatablePbs();
pbs.pbs = { ...pbs.pbs!, nodeName: ' PROXBACK-VM.EXAMPLE. ' };
const agent = makeCorrelatedAgent({
name: 'Custom host label',
displayName: 'Custom display label',
agent:
facet === 'direct'
? { agentId: 'agent-proxback', hostname: 'proxback-vm.example' }
: undefined,
platformData:
facet === 'platform'
? {
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm.example' },
}
: undefined,
});
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
agent.metricsTarget,
);
},
);
it('deduplicates a genuinely matching node/Agent surfaced as both guest and host', () => {
const pbs = makeCorrelatablePbs();
const host = makeCorrelatedAgent();
const guest = {
...host,
id: 'vm-proxback',
type: 'vm',
metricsTarget: {
resourceType: 'vm',
resourceId: 'vm-proxback',
},
} as Resource;
expect(buildBackupServerRows([pbs, host, guest])[0].resource.metricsTarget).toEqual(
guest.metricsTarget,
);
});
it.each([-6, 6])('declines a node-name match with a %s-minute observation gap', (minutes) => {
const pbs = makeCorrelatablePbs();
const agent = makeCorrelatedAgent({ lastSeen: pbs.lastSeen + minutes * 60_000 });
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it.each(['stale', 'old-report', 'invalid-report'])(
'does not let a recent PVE row hide %s Agent evidence',
(state) => {
const pbs = makeCorrelatablePbs();
const agent = makeCorrelatedAgent({
type: 'vm',
agent: {
agentId: 'agent-proxback',
hostname: 'proxback-vm',
stale: state === 'stale',
lastReportAt:
state === 'old-report'
? new Date(pbs.lastSeen - 6 * 60_000).toISOString()
: state === 'invalid-report'
? 'invalid'
: undefined,
},
});
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
},
);
it('declines an observed hostname without a concrete Agent identity', () => {
const pbs = makeCorrelatablePbs();
const agent = makeCorrelatedAgent({ agent: { hostname: 'proxback-vm' } });
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
pbs.metricsTarget,
);
});
it('revokes a retained node-name match when the reported machine identity disappears', () => {
const retention = createPbsCorrelationRetention();
const pbs = makeCorrelatablePbs();
buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention);
expect(retention.size).toBe(1);
const uncorroborated = { ...pbs, pbs: { ...pbs.pbs!, nodeName: undefined } };
expect(
buildBackupServerRows([uncorroborated], [], retention)[0].resource.metricsTarget,
).toEqual(pbs.metricsTarget);
expect(retention.size).toBe(0);
});
});
describe('buildBackupServerRows PBS host correlation retention', () => {
it('keeps three PBS guests on their own History targets across PVE and VirtualBox hosts', () => {
const pbsServers = ['one', 'two', 'three'].map((suffix) => {
@@ -335,12 +540,16 @@ describe('buildBackupServerRows PBS host correlation retention', () => {
const otherAgent = makeCorrelatedAgent({
id: 'agent-other',
platformId: 'agent-other',
agent: { agentId: 'agent-other', hostname: 'proxback' },
agent: { agentId: 'agent-other', hostname: 'proxback-vm' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-other' },
});
const ambiguous = buildBackupServerRows([pbs, agent, otherAgent], [], retention);
expect(ambiguous[0].resource.metricsTarget?.resourceId).toBe('proxback');
expect(retention.size).toBe(0);
expect(buildBackupServerRows([pbs], [], retention)[0].resource.metricsTarget?.resourceId).toBe(
'proxback',
);
});
it('drops a remembered host once it is stale relative to the server', () => {