From 62fb6cdb12dd3db90d2be598df1efc0210be37f1 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:04:47 +0100 Subject: [PATCH] Drop stale PBS host History when backend link is withdrawn Retain the Agent target only while the corroborated PBS link remains unchanged. Cover three concurrent PBS guests and fail closed when that link disappears during a host-row refresh gap. Contract-Neutral: No canonical API or schema delta; this only invalidates a stale frontend correlation when its backend evidence is withdrawn. Change-source: pulse-maintainer --- frontend-modern/browser-verification.json | 99 ++++++++++++++----- .../proxmox/ProxmoxBackupServersTable.tsx | 25 +++-- .../ProxmoxBackupServersTable.test.ts | 64 ++++++++++++ 3 files changed, 153 insertions(+), 35 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 971e11f167..ef219d852b 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,22 +1,16 @@ { "version": 1, - "base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3", - "verified_at": "2026-09-29T22:13:56Z", + "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", + "verified_at": "2026-09-29T23:04:22Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts", - "frontend-modern/src/stores/containerUpdates.ts" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5", - "frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" }, "routes": [ - "/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)" + "/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)" ], "viewports": [ { @@ -29,28 +23,79 @@ } ], "states": [ - "governed update receipt pending after registry updateAvailable becomes false", - "existing action re-opened by GET with no second plan or execute", - "recorded completed action after a second GET changes the displayed outcome" + "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": [ - "inspect pending button and absence of a Current or Completed claim", - "select Review action and inspect receipt-pending dialog", - "select Check for receipt and inspect recorded completion", - "assert both action requests are GET; no page errors or horizontal overflow" + "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 frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "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-ahrmjhme/update-action-receipt-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png" + "/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 UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891.", + "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.", "prior_verifications": [ + { + "version": 1, + "base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3", + "verified_at": "2026-09-29T22:13:56Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx", + "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts", + "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts", + "frontend-modern/src/stores/containerUpdates.ts" + ], + "content_sha256": { + "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9", + "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e", + "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5", + "frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d" + }, + "routes": [ + "/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "governed update receipt pending after registry updateAvailable becomes false", + "existing action re-opened by GET with no second plan or execute", + "recorded completed action after a second GET changes the displayed outcome" + ], + "interactions": [ + "inspect pending button and absence of a Current or Completed claim", + "select Review action and inspect receipt-pending dialog", + "select Check for receipt and inspect recorded completion", + "assert both action requests are GET; no page errors or horizontal overflow" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-browser-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png" + ], + "notes": "Mock-backed UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891." + }, { "version": 1, "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 6a29b01ea5..8c696979d3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -234,7 +234,12 @@ const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource // 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; +interface RetainedPbsCorrelation { + agent: Resource; + linkedAgentId?: string; +} + +export type PbsCorrelationRetention = Map; export const createPbsCorrelationRetention = (): PbsCorrelationRetention => new Map(); @@ -299,22 +304,26 @@ export function buildBackupServerRows( .map((server) => { const agent = uniquelyCorrelatedAgent(server, servers); if (agent) { - retention?.set(server.id, agent); + retention?.set(server.id, { + agent, + linkedAgentId: server.pbs?.linkedAgentId?.trim() || undefined, + }); return mergePBSAgentPresentation(server, agent); } const retained = retention?.get(server.id); if (retained) { - // A changed backend link is evidence of host replacement; an old - // correlation must not survive merely because its row is absent. - const linkedAgentId = server.pbs?.linkedAgentId?.trim(); - if (linkedAgentId && correlatedAgentKey(retained) !== linkedAgentId) { + // 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) { retention?.delete(server.id); return server; } const fresh = - server.lastSeen - retained.lastSeen <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS; + server.lastSeen - retained.agent.lastSeen <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS; if (fresh && !hasCorrelationCandidate(server, servers)) { - return mergePBSAgentPresentation(server, retained); + return mergePBSAgentPresentation(server, retained.agent); } if (!fresh) retention?.delete(server.id); } diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts index 46d366a2e3..088026484b 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts @@ -146,6 +146,54 @@ const makeCorrelatedAgent = (overrides: Partial = {}): Resource => }) as Resource; 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) => { + const pbs = makeCorrelatablePbs(); + pbs.id = `pbs-${suffix}`; + pbs.name = `backup-connection-${suffix}`; + pbs.pbs = { + ...pbs.pbs!, + instanceId: `pbs-${suffix}`, + hostname: `10.2.0.${suffix === 'one' ? 13 : suffix === 'two' ? 23 : 33}`, + nodeName: undefined, + linkedAgentId: `agent-${suffix}`, + }; + pbs.metricsTarget = { resourceType: 'agent', resourceId: `pbs-${suffix}` }; + return pbs; + }); + const agents = ['one', 'two', 'three'].map((suffix) => + makeCorrelatedAgent({ + id: `agent-${suffix}`, + name: `guest-${suffix}`, + agent: { agentId: `agent-${suffix}`, hostname: `guest-${suffix}` }, + metricsTarget: { resourceType: 'agent', resourceId: `agent-${suffix}` }, + }), + ); + const pveGuests = agents.slice(0, 2).map((agent): Resource => ({ + ...agent, + id: `vm-${agent.agent!.agentId}`, + type: 'vm', + sources: ['proxmox', 'agent'], + metricsTarget: { resourceType: 'vm', resourceId: `vm-${agent.agent!.agentId}` }, + })); + const otherAgents = Array.from({ length: 6 }, (_, index) => + makeCorrelatedAgent({ + id: `agent-unrelated-${index}`, + name: `backup-connection-${index % 3 === 0 ? 'one' : index % 3 === 1 ? 'two' : 'three'}`, + agent: { agentId: `agent-unrelated-${index}` }, + }), + ); + + const rows = buildBackupServerRows([...pbsServers, ...agents, ...pveGuests, ...otherAgents]); + expect( + rows.map((row) => [row.resource.id, row.resource.agent?.agentId, row.resource.metricsTarget]), + ).toEqual([ + ['pbs-one', 'agent-one', { resourceType: 'vm', resourceId: 'vm-agent-one' }], + ['pbs-three', 'agent-three', { resourceType: 'agent', resourceId: 'agent-three' }], + ['pbs-two', 'agent-two', { resourceType: 'vm', resourceId: 'vm-agent-two' }], + ]); + }); + it('keeps the agent UUID through PBS-only to side-by-side PVE/PBS refreshes without nodeName', () => { const retention = createPbsCorrelationRetention(); const pbs = makeCorrelatablePbs(); @@ -245,6 +293,22 @@ describe('buildBackupServerRows PBS host correlation retention', () => { expect(retention.size).toBe(0); }); + it('drops retained host History when a backend link is withdrawn', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + pbs.pbs = { ...pbs.pbs!, linkedAgentId: 'agent-proxback' }; + expect( + buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention)[0].resource.metricsTarget + ?.resourceId, + ).toBe('agent-proxback'); + + const withdrawn = { ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: undefined } } as Resource; + expect( + buildBackupServerRows([withdrawn], [], retention)[0].resource.metricsTarget?.resourceId, + ).toBe('proxback'); + expect(retention.size).toBe(0); + }); + it('retains the resolved host target while a refresh snapshot omits the host row', () => { const retention = createPbsCorrelationRetention(); const pbs = makeCorrelatablePbs();