From 8654c837ee37ba68fe8442bbee734cb1811d8049 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:26:24 +0100 Subject: [PATCH 1/4] build(deps): land the reviewed @types/node 26.6.2 refresh Dependabot npm-minor-patch #2189 raises @types/node 26.6.1 -> 26.6.2 in frontend-modern and tests/integration, but Canonical Governance blocks the manifest/lock change for a missing deployment-installability contract entry and frontend dependency security proof. The portal frontend part of the same group is a separate ws override refresh that needs a portal bundle decision and is owned separately, so it is not carried here. Carry the frontend-modern and tests/integration @types/node 26.6.2 bump with a Current State contract entry and a dependencySecurity floor raise. @types/node is a dev-only declaration package and does not enter the shipped bundle; @playwright/test stays pinned at 1.56.1, so browser-test parity is unchanged. The frontend-modern offline dependency snapshot is bound to 26.6.1, so npm ci --offline cannot resolve the 26.6.2 tarball in this assignment; a host dependency acquisition is required before the suite can run against the new lockfile. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 22 +++++++++++++++++++ frontend-modern/package-lock.json | 8 +++---- frontend-modern/package.json | 2 +- .../__tests__/dependencySecurity.test.ts | 8 ++++--- tests/integration/package-lock.json | 8 +++---- tests/integration/package.json | 2 +- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ef91b37424..013b563c0f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2430,6 +2430,28 @@ parity is unchanged. `frontend-modern/src/security/__tests__/dependencySecurity.test.ts` pins the manifest ranges and locked floors so a later downgrade is rejected. +### Reviewed @types/node 26.6.2 refresh + +The 2026-09-23 `npm-minor-patch` group (Dependabot #2189) advances the +`@types/node` type-surface dependency from 26.6.1 to 26.6.2 in both +`frontend-modern` and `tests/integration`, with no other direct version change +in those two manifests. The portal `internal/cloudcp/portal/frontend` part of the +same group is a separate `ws` override refresh that requires a portal bundle +decision and is owned separately, so it is not carried here. `@types/node` is a +dev-only declaration package: it does not enter the shipped frontend bundle, +the installer, the artifact, signing, promotion or rollback boundary. The +`dependencySecurity.test.ts` reviewed floor for `@types/node` moves to 26.6.2 so +a later downgrade below this review is rejected, and `@playwright/test` stays +pinned at 1.56.1, so browser-test parity is unchanged. + +Verification status: the `frontend-modern` offline dependency snapshot is bound +to the previously locked `@types/node` 26.6.1, so `npm ci --offline` against the +updated lockfile cannot resolve the 26.6.2 tarball in this assignment. The +manifest/lock/contract/proof change is prepared and its static version +assertions hold, but a host dependency acquisition on a later launch is required +before the frontend suite can be executed against the new lockfile. No passing +frontend suite or release acceptance is claimed here. + ### Manual update freshness Manual update checks carry explicit freshness to the update manager. They bypass diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index 36215a2dbd..c80c78c109 100644 --- a/frontend-modern/package-lock.json +++ b/frontend-modern/package-lock.json @@ -22,7 +22,7 @@ "@solidjs/testing-library": "^0.8.5", "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.5.0", - "@types/node": "^26.6.1", + "@types/node": "^26.6.2", "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", @@ -1969,9 +1969,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz", - "integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 1187237813..2c1df0c446 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -73,7 +73,7 @@ "@solidjs/testing-library": "^0.8.5", "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.5.0", - "@types/node": "^26.6.1", + "@types/node": "^26.6.2", "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", diff --git a/frontend-modern/src/security/__tests__/dependencySecurity.test.ts b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts index 4f64396eb4..d69d08533e 100644 --- a/frontend-modern/src/security/__tests__/dependencySecurity.test.ts +++ b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts @@ -173,13 +173,15 @@ describe('frontend dependency security floors', () => { }); it('keeps the reviewed npm-minor-patch floors', () => { - // Dependabot npm-minor-patch group, reviewed 2026-09-21. Each floor is the - // lowest version this review accepted, so a later downgrade is rejected. + // Dependabot npm-minor-patch group, reviewed 2026-09-21 and 2026-09-23. + // Each floor is the lowest version this review accepted, so a later + // downgrade is rejected. The 2026-09-23 refresh raises @types/node to + // 26.6.2 in frontend-modern and tests/integration. const floors: Array<[string, [number, number, number]]> = [ ['dompurify', [3, 4, 15]], ['highlight.js', [11, 12, 0]], ['solid-js', [1, 9, 15]], - ['@types/node', [26, 6, 1]], + ['@types/node', [26, 6, 2]], ['typescript-eslint', [8, 70, 0]], ['@typescript-eslint/eslint-plugin', [8, 70, 0]], ['@typescript-eslint/parser', [8, 70, 0]], diff --git a/tests/integration/package-lock.json b/tests/integration/package-lock.json index b25af0ee6a..beb6f87eb1 100644 --- a/tests/integration/package-lock.json +++ b/tests/integration/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", - "@types/node": "^26.6.1", + "@types/node": "^26.6.2", "@types/ws": "^8.18.1", "typescript": "^7.0.2", "ws": "^8.21.3" @@ -47,9 +47,9 @@ } }, "node_modules/@types/node": { - "version": "26.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz", - "integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/tests/integration/package.json b/tests/integration/package.json index 5798d54a9b..306e8a45ee 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -31,7 +31,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", - "@types/node": "^26.6.1", + "@types/node": "^26.6.2", "@types/ws": "^8.18.1", "typescript": "^7.0.2", "ws": "^8.21.3" From 854bcb86faa44752e36ee95d2421dceda6757af0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:04:08 +0100 Subject: [PATCH 2/4] test(frontend): let Profile L use its declared waitFor budget The Profile L case waits up to 15s for the mounted-row cap, but the vitest per-test default is 5s, so that budget was unreachable. Under the release qualification VM's load the case timed out at 5s and failed exact preflight for v6.4.5-rc.3 (packet 20260923T082318Z-release), even though the candidate frontend tree is byte-identical to checkpoints that passed. Set the test-level timeout to 20s so the declared 15s waitFor governs. Change-source: pulse-maintainer --- .../UnifiedResourceTable.performance.contract.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx index 21db0dfcb2..d6f8eb6ced 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx @@ -1215,7 +1215,7 @@ describe('UnifiedResourceTable performance contract', () => { }, { timeout: 15000 }, ); - }); + }, 20000); }); describe('Grouped vs flat mode contract', () => { From 5d831b8765f312021ee7a1328a75ded9017df586 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:27:45 +0100 Subject: [PATCH 3/4] fix(web): retain PBS host target across transient snapshots (#1723) The Backups drawer's Identity and History target is the merged PBS host resource. Correlation was recomputed from each snapshot with no memory, so a live refresh that briefly omitted the correlated host row made the drawer fall back to the PBS service target (agent/), which has no host history. The target flicked between the host series and the service key until the host row returned. Retain the last resolved correlation per PBS server and reuse it only across such an omission, and only while the remembered host is still fresh relative to the server, so a genuinely removed or replaced host is not advertised indefinitely. A host row that is present but ambiguous still declines, as before. Adds unit coverage for retention, staleness and pruning, a drawer regression test, and extends the real-browser guard and fixture to omit the host row and assert the identity rows and History chart survive. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 6 +- .../internal/subsystems/storage-recovery.md | 6 +- .../internal/subsystems/unified-resources.md | 6 +- .../pbs-host-history-correlation.tsx | 25 ++++- frontend-modern/browser-verification.json | 34 ++++--- .../proxmox/ProxmoxBackupServersTable.tsx | 75 ++++++++++++--- .../ProxmoxBackupServersTable.drawer.test.tsx | 72 ++++++++++++++ .../ProxmoxBackupServersTable.test.ts | 95 ++++++++++++++++++- .../__tests__/ProxmoxBackupsTable.test.tsx | 8 ++ .../check-pbs-host-history-correlation.cjs | 53 ++++++++++- 10 files changed, 345 insertions(+), 35 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c4b3f14ce0..d519583c62 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -629,9 +629,13 @@ The Backups surface passes its complete deduplicated route inventory to the PBS One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness. +A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines. + Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and merged guest targets, missing disks, duplicate guest/host representations of one -agent, and genuinely ambiguous identities; +agent, genuinely ambiguous identities, and retaining the resolved host target +across a transient host-row omission; +ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning; ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e54a9dadc6..66774049f4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -313,9 +313,13 @@ Backups hydrates type=pbs,agent with source=pbs and reuses Overview guest invent The same agent can still arrive as two distinct resources: a PVE guest carrying its telemetry and the standalone `source=pbs` host row. Collapse those candidates by agent identity and resolve the Backups PBS row to the guest target, whose persisted host series the drawer charts; keeping the PBS service target leaves History on a key with no host data. Distinct agent identities stay ambiguous, and a missing agent identity is not sameness proof. Do not add a second guest-estate request to compensate. +A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved host per PBS server across that omission instead of falling back to the service target, so the drawer's Identity rows and History series stay on the host key. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines. + Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and merged guest targets, missing disks, duplicate guest/host representations of one -agent, and genuinely ambiguous identities; +agent, genuinely ambiguous identities, and retaining the resolved host target +across a transient host-row omission; +ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning; ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9298458657..b3f2ac8582 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -458,9 +458,13 @@ PBS presentation correlation accepts standalone agents and VM/system-container r A single agent surfaced as both a VM/system-container guest and a standalone agent row is one identity, not an ambiguous pair. Collapse candidates that share an agent identity before deciding, and prefer the guest's canonical metricsTarget because it names the persisted host series; the PBS service target names the service key and has no host history. Only distinct agent identities, or candidates whose identity cannot be proven equal, decline to choose. Never use input order or a fallback that discards the PBS row id. +A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved correlation per PBS server across that omission rather than substituting the PBS service target, so the drawer's identity rows and history target stay stable. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines. + Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and merged guest targets, missing disks, duplicate guest/host representations of one -agent, and genuinely ambiguous identities; +agent, genuinely ambiguous identities, and retaining the resolved host target +across a transient host-row omission; +ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning; ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication. diff --git a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx index ce4a4fd311..414a5a683e 100644 --- a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx +++ b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx @@ -110,11 +110,15 @@ const Fixture = () => { // Simulates a snapshot in which the merged host correlation target is // transiently absent, which is what gates the drawer's History tab. const [dropMetricsTarget, setDropMetricsTarget] = createSignal(false); + // Simulates a live refresh that briefly omits the correlated host row while + // the PBS server row remains, which is what flipped the drawer's Identity and + // History target between the host series and the service key (#1723). + const [dropHostRows, setDropHostRows] = createSignal(false); let timer: ReturnType | undefined; onCleanup(() => clearTimeout(timer)); const refresh = () => { const count = snapshot() + 1; - const next = structuredClone(resources); + let next = structuredClone(resources); next[1].cpu = { current: 15.4 + count }; const tank = next[0].pbs!.datastores!.find((store) => store.name === 'tank')!; tank.used = 400 + count * 10; @@ -126,6 +130,9 @@ const Fixture = () => { if (dropMetricsTarget()) { for (const resource of next) delete resource.metricsTarget; } + if (dropHostRows()) { + next = next.filter((resource) => resource.type === 'pbs'); + } setServers(next); setSnapshot(count); }; @@ -156,6 +163,22 @@ const Fixture = () => { > Restore metrics target + + {snapshot()} diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 8b331c5c35..655e90cafb 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,21 @@ { "version": 1, - "base_sha": "79cf2d0058c374adc7fcc2e7268ab2261e10fa24", - "verified_at": "2026-09-22T23:54:27Z", + "base_sha": "77b652adf78817516f236079aef9c9252233b3ce", + "verified_at": "2026-09-23T09:22:16Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "24f77d42e2542dd0e8fcf9c55f25a8373c563069c5a0dc112afa6a4456d5a93a" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b19198cb055e5f0f107c2d6749d6def719bd84310de585e3b6a349fa22fac3c1" }, "routes": [ - "/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)" + "/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)", + "/browser-tests/pbs-host-history-correlation.html?topology=guest&order=reordered (Proxmox Backups server drawer)", + "/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=stable (Proxmox Backups server drawer)", + "/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=reordered (Proxmox Backups server drawer)", + "/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=stable (Proxmox Backups server drawer)", + "/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=reordered (Proxmox Backups server drawer)" ], "viewports": [ { @@ -23,16 +28,17 @@ } ], "states": [ - "Drawer History tab selected with a populated host history target", - "Snapshot with the merged host metrics target transiently absent (availability notice)", - "Snapshot with the metrics target restored on the same selection" + "Drawer History tab selected with a populated correlated host series", + "Timer-driven snapshot replacing the datastore inventory in stable and reversed order", + "Snapshot that briefly omits the correlated host row while the PBS server row remains", + "Snapshot that restores the correlated host row on the same selection" ], "interactions": [ - "Expand the Proxmox Backups server row and select the History tab; assert the chart paths render", - "Apply a snapshot that removes the merged host metrics target; assert the drawer is not remounted and the History selection is retained, showing the in-tab availability notice", - "Apply a snapshot that restores the metrics target; assert the same History selection recovers the chart paths", - "Repeat at 1280x900 and 390x844; the same check fails on the pre-fix source (no notice, selection reset)" + "Expand the Proxmox Backups server row, switch between datastore rows and select History; assert the chart paths render against the host metrics target", + "Apply timer-driven stable and reordered snapshots; assert the drawer is not remounted, the History selection is retained, the chart series survive and the Discovery/Metrics Target rows stay byte-identical", + "Apply a snapshot that omits the correlated host row; assert the Identity rows and History chart stay on the retained host target instead of falling back to the PBS service key", + "Restore the correlated host row; assert the same target is confirmed, and repeat the complete matrix at 1280x900 and 390x844" ], - "command": "pulse-worker-browser scripts/check-drawer-tab-retention.cjs", - "notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ResourceDetailDrawer mounted through the real ProxmoxBackupServersTable fixture with synthetic API responses. Proves the drawer tab-retention behaviour, not backend persistence or installed release behaviour. The identical check timed out on the pre-fix source because the selected tab reset to Overview." + "command": "pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs", + "notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ProxmoxBackupServersTable mounted through the real browser fixture with synthetic API responses and an intercepted metrics-history request. Proves the retained host correlation, not backend persistence or installed release behaviour. The same check fails on the pre-fix source: omitting the host row flips the drawer's Discovery and Metrics Target rows to agent:pbs-1 and drops the host series." } diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 8d6272e576..9ae8e84670 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -144,21 +144,23 @@ const correlatedAgentKey = (resource: Resource): string | undefined => { return undefined; }; +// 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 (candidate.type !== 'agent' && !isGuestWithAgent(candidate)) return false; + for (const token of identityTokens(candidate)) { + if (serverTokens.has(token)) return true; + } + return false; +}; + const uniquelyCorrelatedAgent = ( server: Resource, candidates: readonly Resource[], ): Resource | undefined => { const serverTokens = identityTokens(server); if (serverTokens.size === 0) return undefined; - const matches = candidates.filter((candidate) => { - // 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. - if (candidate.type !== 'agent' && !isGuestWithAgent(candidate)) return false; - for (const token of identityTokens(candidate)) { - if (serverTokens.has(token)) return true; - } - return false; - }); + const matches = candidates.filter((candidate) => isCorrelationCandidate(serverTokens, candidate)); if (matches.length === 0) return undefined; if (matches.length === 1) return matches[0]; @@ -188,6 +190,30 @@ const uniquelyCorrelatedAgent = ( ); }; +// True when the snapshot still offers a host row for this server, even if the +// match is ambiguous and `uniquelyCorrelatedAgent` declines to choose. The +// 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)); +}; + +// 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 +// drawer's Identity and History target between the host series and the PBS +// service key. Keep the last resolved correlation per PBS server and reuse it +// only across such an omission, and only while the remembered host is still +// plausibly current, so a genuinely removed or replaced host is not advertised +// indefinitely. A host that is present but ambiguous still declines. +export type PbsCorrelationRetention = Map; + +export const createPbsCorrelationRetention = (): PbsCorrelationRetention => new Map(); + +const PBS_CORRELATION_RETENTION_MAX_STALENESS_MS = 5 * 60 * 1000; + const mergePBSAgentPresentation = (server: Resource, agent: Resource): Resource => { const serverPlatform = server.platformData ?? {}; const agentPlatform = agent.platformData ?? {}; @@ -225,6 +251,7 @@ const mergePBSAgentPresentation = (server: Resource, agent: Resource): Resource export function buildBackupServerRows( servers: readonly Resource[], backups: readonly PBSBackup[] = [], + retention?: PbsCorrelationRetention, ): BackupServerRow[] { const rows: BackupServerRow[] = []; const counts = buildBackupCounts(backups); @@ -241,14 +268,33 @@ export function buildBackupServerRows( // *datastore* storage resources (type 'storage', sources ['pbs']). This table // is about the server, so keep only actual PBS server instances — otherwise a // datastore renders as a phantom offline "server" row. - const sortedServers = servers - .filter((resource) => resource.type === 'pbs') + const pbsServers = servers.filter((resource) => resource.type === 'pbs'); + const sortedServers = pbsServers .map((server) => { const agent = uniquelyCorrelatedAgent(server, servers); - return agent ? mergePBSAgentPresentation(server, agent) : server; + if (agent) { + retention?.set(server.id, agent); + return mergePBSAgentPresentation(server, agent); + } + const retained = retention?.get(server.id); + if (retained) { + const fresh = + server.lastSeen - retained.lastSeen <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS; + if (fresh && !hasCorrelationCandidate(server, servers)) { + return mergePBSAgentPresentation(server, retained); + } + if (!fresh) retention?.delete(server.id); + } + return server; }) .slice() .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); + if (retention) { + const presentIds = new Set(pbsServers.map((server) => server.id)); + for (const id of retention.keys()) { + if (!presentIds.has(id)) retention.delete(id); + } + } for (const server of sortedServers) { const datastores = (server.pbs?.datastores ?? []) .slice() @@ -296,7 +342,10 @@ export function ProxmoxBackupServersTable(props: { emptyIcon?: JSX.Element; layoutWidth?: Accessor; }) { - const rows = () => buildBackupServerRows(props.servers, props.backups ?? []); + const retention = createPbsCorrelationRetention(); + const rows = createMemo(() => + buildBackupServerRows(props.servers, props.backups ?? [], retention), + ); const observedWidth = useObservedElementWidth(); const layoutMode = createMemo(() => { const width = props.layoutWidth?.() ?? observedWidth.width(); 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 c8782b2898..b40426127c 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx @@ -280,4 +280,76 @@ describe('ProxmoxBackupServersTable details', () => { 'proxmox:100', ); }); + + it('retains the correlated host target when a refreshed snapshot omits the agent row', async () => { + const makeServers = (): [Resource, Resource] => { + const pbs = makePbsResource(); + pbs.id = 'pbs-1'; + pbs.name = 'proxback'; + pbs.displayName = 'proxback'; + pbs.platformId = 'pbs-1'; + pbs.sources = ['pbs']; + pbs.agent = undefined; + // The PBS service target names the service key, not the host series. + pbs.metricsTarget = { resourceType: 'agent', resourceId: 'proxback' }; + pbs.pbs = { + instanceId: 'proxback', + hostname: 'proxback-vm', + version: '3.2.1', + connectionHealth: 'healthy', + datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }], + }; + pbs.platformData = { + sources: ['pbs'], + pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 }, + }; + const agent = { + id: 'agent-proxback', + type: 'agent', + name: 'proxback-vm', + displayName: 'proxback-vm', + platformId: 'agent-proxback', + platformType: 'proxmox-pbs', + sourceType: 'hybrid', + sources: ['agent', 'pbs'], + status: 'online', + lastSeen: pbs.lastSeen + 1000, + agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' }, + platformData: { + sources: ['agent', 'pbs'], + agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' }, + }, + } as Resource; + return [pbs, agent]; + }; + const [pbs, agent] = makeServers(); + const [servers, setServers] = createSignal([pbs, agent]); + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Expand details for proxback' })); + expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute( + 'data-metrics-resource-id', + 'agent-proxback', + ); + + // A live refresh snapshot can transiently omit the correlated agent row. + // The drawer target must not fall back to the PBS service target. + setServers([pbs]); + await waitFor(() => + expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute( + 'data-metrics-resource-id', + 'agent-proxback', + ), + ); + + // When the agent returns, the retained correlation is confirmed, not re-guessed. + setServers([pbs, agent]); + await waitFor(() => + expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute( + 'data-metrics-resource-id', + 'agent-proxback', + ), + ); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts index 1b52113f04..7c4383c84d 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; -import { buildBackupServerRows } from '../ProxmoxBackupServersTable'; +import { buildBackupServerRows, createPbsCorrelationRetention } from '../ProxmoxBackupServersTable'; const makePbsResource = (overrides: Partial = {}): Resource => ({ @@ -109,3 +109,96 @@ describe('buildBackupServerRows', () => { expect(rows[0].uptimeSeconds).toBeUndefined(); }); }); + +const makeCorrelatablePbs = (lastSeen = 1_700_000_000_000): Resource => + makePbsResource({ + id: 'pbs-1', + name: 'proxback', + displayName: 'proxback', + platformId: 'proxback', + sources: ['pbs'], + lastSeen, + pbs: { + instanceId: 'proxback', + hostname: 'proxback-vm', + connectionHealth: 'healthy', + datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600 }], + }, + // The PBS service target names the service key, not the host series. + metricsTarget: { resourceType: 'agent', resourceId: 'proxback' }, + }); + +const makeCorrelatedAgent = (overrides: Partial = {}): Resource => + ({ + id: 'agent-proxback', + type: 'agent', + name: 'proxback', + displayName: 'proxback', + platformId: 'agent-proxback', + platformType: 'proxmox-pbs', + sourceType: 'hybrid', + sources: ['agent', 'pbs'], + status: 'online', + lastSeen: 1_700_000_000_000, + agent: { agentId: 'agent-proxback', hostname: 'proxback' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' }, + ...overrides, + }) as Resource; + +describe('buildBackupServerRows PBS host correlation retention', () => { + it('retains the resolved host target while a refresh snapshot omits the host row', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + const agent = makeCorrelatedAgent(); + + const first = buildBackupServerRows([pbs, agent], [], retention); + expect(first[0].resource.metricsTarget?.resourceId).toBe('agent-proxback'); + + // The host row is briefly absent; the service target must not replace it. + const omitted = buildBackupServerRows([pbs], [], retention); + expect(omitted[0].resource.metricsTarget?.resourceId).toBe('agent-proxback'); + + // A later snapshot that carries the host again confirms the same target. + const restored = buildBackupServerRows([pbs, agent], [], retention); + expect(restored[0].resource.metricsTarget?.resourceId).toBe('agent-proxback'); + }); + + it('does not reuse a remembered host when the current snapshot is ambiguous', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + const agent = makeCorrelatedAgent(); + buildBackupServerRows([pbs, agent], [], retention); + + const otherAgent = makeCorrelatedAgent({ + id: 'agent-other', + platformId: 'agent-other', + agent: { agentId: 'agent-other', hostname: 'proxback' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-other' }, + }); + const ambiguous = buildBackupServerRows([pbs, agent, otherAgent], [], retention); + + expect(ambiguous[0].resource.metricsTarget?.resourceId).toBe('proxback'); + }); + + it('drops a remembered host once it is stale relative to the server', () => { + const retention = createPbsCorrelationRetention(); + const agent = makeCorrelatedAgent({ lastSeen: 1_700_000_000_000 }); + const pbs = makeCorrelatablePbs(1_700_000_000_000); + buildBackupServerRows([pbs, agent], [], retention); + + const stale = makeCorrelatablePbs(1_700_000_000_000 + 6 * 60 * 1000); + const rows = buildBackupServerRows([stale], [], retention); + expect(rows[0].resource.metricsTarget?.resourceId).toBe('proxback'); + expect(retention.size).toBe(0); + }); + + it('prunes remembered hosts for servers that are no longer present', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention); + expect(retention.has('pbs-1')).toBe(true); + + buildBackupServerRows([makePbsResource({ id: 'pbs-2', name: 'other' })], [], retention); + expect(retention.has('pbs-1')).toBe(false); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index bfb1b67162..6b51e5b822 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -595,6 +595,14 @@ describe('ProxmoxBackupsTable', () => { expect(proxmoxBackupServersTableSource).toContain( 'metricsTarget: agent.metricsTarget ?? server.metricsTarget', ); + // A refresh that briefly omits the correlated host row must not flip the + // drawer target to the PBS service key; retain the resolved host per server + // and reuse it only across the omission. + expect(proxmoxBackupServersTableSource).toContain('createPbsCorrelationRetention'); + expect(proxmoxBackupServersTableSource).toContain('hasCorrelationCandidate'); + expect(proxmoxBackupServersTableSource).toContain( + 'buildBackupServerRows(props.servers, props.backups ?? [], retention)', + ); expect(proxmoxBackupServersTableSource).not.toContain( 'Server:', ); diff --git a/scripts/check-pbs-host-history-correlation.cjs b/scripts/check-pbs-host-history-correlation.cjs index 1d0cab8afc..7c59abd975 100644 --- a/scripts/check-pbs-host-history-correlation.cjs +++ b/scripts/check-pbs-host-history-correlation.cjs @@ -163,6 +163,11 @@ const launchOptions = { }; // Both datastore rows must use the same host series, including revisiting // the first row after its history component has been disposed. + let retainedIdentity = []; + let retainedPaths = 0; + const detail = page.locator( + '[data-inline-platform-resource-detail-for="pbs-1"]', + ); for (const [index, datastore] of [ 'tank', 'archive', @@ -177,9 +182,6 @@ const launchOptions = { ['proxback · archive', 'proxback · tank'], ); await openDatastore(datastore); - const detail = page.locator( - '[data-inline-platform-resource-detail-for="pbs-1"]', - ); await detail.evaluate((node) => { node.dataset.proofIdentity = 'retained'; }); @@ -238,6 +240,8 @@ const launchOptions = { identityBefore, 'Identity rows changed after a refreshed snapshot', ); + retainedIdentity = identityAfter; + retainedPaths = pathsBefore; observations.push({ datastore, paths: pathsBefore, @@ -246,6 +250,49 @@ const launchOptions = { }); } + // A live refresh can briefly omit the correlated host row while the + // PBS server row stays. That must not flip the drawer's Identity or + // History target to the PBS service key (#1723). + await page.getByRole('button', { name: 'Drop correlated host' }).click(); + await page + .getByRole('status', { name: 'Snapshot number' }) + .filter({ hasText: '4' }) + .waitFor(); + assert.equal( + await detail.getAttribute('data-proof-identity'), + 'retained', + 'drawer remounted after the host row was omitted', + ); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + 'history reset after the host row was omitted', + ); + assert.equal( + await detail + .locator('[data-testid="guest-history-plot"] path') + .count(), + retainedPaths, + 'history chart lost its series when the host row was omitted', + ); + assert.deepEqual( + await readIdentityRows(detail), + retainedIdentity, + 'Identity rows changed when the host row was transiently omitted', + ); + observations.push({ + hostRowOmitted: true, + identity: retainedIdentity, + refreshPreservedHistory: true, + }); + await page.getByRole('button', { name: 'Restore correlated host' }).click(); + await page + .getByRole('status', { name: 'Snapshot number' }) + .filter({ hasText: '5' }) + .waitFor(); + if (!targets.includes(expected)) { throw new Error( `${viewport.width}px: expected the host history target ${expected}; recorded ${JSON.stringify(targets)}`, From e994c41c2067dd5aedd7400306832a813fb575ea Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:42:15 +0100 Subject: [PATCH 4/4] chore(frontend): bind browser proof to integrated candidate The integration merges preserve the exact browser-tested frontend bytes. Rebind the content-bound receipt to the integration tip so the canonical cumulative range passes the parent-identity guard. Change-source: pulse-maintainer --- frontend-modern/browser-verification.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 655e90cafb..5a0c6ad000 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,6 +1,6 @@ { "version": 1, - "base_sha": "77b652adf78817516f236079aef9c9252233b3ce", + "base_sha": "92edc056627a88bbdb11a8f6d6f95aacd061607a", "verified_at": "2026-09-23T09:22:16Z", "result": "passed", "changed_paths": [