diff --git a/frontend-modern/browser-tests/pbs-identity-boundary.cjs b/frontend-modern/browser-tests/pbs-identity-boundary.cjs
new file mode 100644
index 0000000000..01ab2a9b0a
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-identity-boundary.cjs
@@ -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;
+});
diff --git a/frontend-modern/browser-tests/pbs-identity-boundary.html b/frontend-modern/browser-tests/pbs-identity-boundary.html
new file mode 100644
index 0000000000..f09fc08bf2
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-identity-boundary.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ PBS identity boundary verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/pbs-identity-boundary.tsx b/frontend-modern/browser-tests/pbs-identity-boundary.tsx
new file mode 100644
index 0000000000..bc3881d00b
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-identity-boundary.tsx
@@ -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 (
+
+ PBS identity boundary verification
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+render(() => , document.getElementById('root') as HTMLElement);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index ef219d852b..5578b0c1b4 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -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",
diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
index 8c696979d3..313270eca0 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
@@ -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 => [
- 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 =>
- 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, 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;
@@ -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.
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx
index 52a65298c5..05854d4787 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx
@@ -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(() => );
+ 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(() => );
+ 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 }],
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts
index 088026484b..6dfa3b16c3 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts
@@ -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 =>
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', () => {