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

+
+ + + + + +
+ {mode()} + +
+ ); +}; + +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', () => {