From 5e7c6bb7d9c813022b235aac84dc37eb9d1d9628 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:43:53 +0100 Subject: [PATCH] Restore PBS History for mixed Agent and PVE sources Preserve the registry-corroborated PBS-to-Agent link on the service row while selecting the Agent host metrics target in the Backups drawer. Reject PVE-only name collisions and retain the target through a transient missing host row. Recompose the unchanged #1723 runtime repair on current main with fresh desktop and phone browser proof. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 6 + .../internal/subsystems/storage-recovery.md | 2 + .../internal/subsystems/unified-resources.md | 14 +- .../browser-tests/pbs-mixed-source-1723.html | 8 + .../browser-tests/pbs-mixed-source-1723.tsx | 100 ++++++++++++ frontend-modern/browser-verification.json | 142 ++++++++++++------ .../proxmox/ProxmoxBackupServersTable.tsx | 39 ++++- .../ProxmoxBackupServersTable.test.ts | 99 ++++++++++++ .../__tests__/ProxmoxBackupsTable.test.tsx | 39 +++++ frontend-modern/src/types/resource.ts | 1 + .../canonical_identity_test.go | 33 ++++ internal/unifiedresources/pbs_host_disks.go | 10 ++ .../unifiedresources/pbs_pmg_registry_test.go | 58 +++++++ internal/unifiedresources/types.go | 10 +- 14 files changed, 505 insertions(+), 56 deletions(-) create mode 100644 frontend-modern/browser-tests/pbs-mixed-source-1723.html create mode 100644 frontend-modern/browser-tests/pbs-mixed-source-1723.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4fae6b402f..a5b42ec2dc 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -411,6 +411,12 @@ telemetry, host details, and stored History use the Agent facet and Agent metrics target. Zero or multiple matches must leave the PBS resource unchanged rather than guessing; this presentation correlation must not mutate either canonical input or create a second mobile disclosure interaction. +When the PBS service has a registry-corroborated `pbs.linkedAgentId`, the +Backups drawer selects only an Agent-bearing resource with that source-native +ID, even if same-host PVE API labels or a token-auth PBS connection have no +matching hostname. PVE-only rows cannot masquerade as a host series. A new +link cannot reuse a retained old host target, and the displayed PBS row keeps +its own canonical resource ID while History uses the selected host target. Presentation helpers that mirror a server-side classification must name the predicate they mirror and expose it as a single exported function rather than diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 293ae1f919..e5d54d286f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -313,6 +313,8 @@ 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. +For token-auth PBS connections that cannot report `nodeName`, a registry-corroborated `pbs.linkedAgentId` takes precedence over connection labels when selecting the Agent-bearing host row. It must resolve to the same source-native Agent ID on a standalone or PVE-merged host; a PVE-only node with no Agent facet cannot supply host History. The PBS row and service target stay canonical; only its drawer presentation uses the host metrics target. If the explicit link changes, retained correlation to the former host is discarded rather than carrying old charts across a replacement. + 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6305b11959..5fde1efcf9 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -506,6 +506,8 @@ about the same disk cannot diverge. PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target. +When the registry uniquely corroborates a PBS connection to a host agent by node name, connection name, endpoint hostname or reported interface IP, the PBS service may expose that source-native agent ID as `pbs.linkedAgentId`. This is a presentation link, not a replacement for the PBS service's canonical ID or metrics target. Token-auth connections may lack `nodeName`; consumers must use the explicit link in preference to display-name matching and must not infer one from an IP or name themselves. PVE-only nodes without an actual Agent facet are not host-history candidates, even if their name matches the PBS connection. A changed link invalidates any remembered correlation to an old host. + 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. @@ -1070,7 +1072,10 @@ container inventory table. provider-owned resource to the standalone Machines list. Presentation-only correlation retains the canonical PBS row id and service facet, uses the canonical host/guest metrics target and telemetry facets, and fails closed when no host - matches or more than one candidate matches. It must not alter registry + matches or more than one candidate matches. A registry-corroborated + `pbs.linkedAgentId` selects the matching Agent facet even when token-auth + PBS has no node name or a same-host PVE API node has a different label; + a PVE-only row cannot supply host History. It must not alter registry identity or treat input order as correlation evidence. 13. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx` shared with `storage-recovery`: Proxmox workload coverage rows are both a storage/recovery protection-posture surface and a unified-resource identity consumer boundary. 14. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` shared with `storage-recovery`: Proxmox recoverable workload table rows are both a storage/recovery coverage surface and a unified-resource platform-table consumer boundary. @@ -5136,6 +5141,13 @@ host is uniquely corroborated by the PBS instance name, endpoint hostname, or endpoint IP against the host report and interface addresses. Multiple matches are ambiguous and must fail closed without assigning PBS ownership. +The same unique match may set `PBSData.LinkedAgentID` on the PBS service only +after the source-native host ID resolves to a canonical resource carrying that +Agent facet. This link is for Backups host-history presentation; it does not +merge the PBS service and host, change either metrics target, or turn PBS API +polls into a host-agent time series. A missing or ambiguous host leaves the +link absent. + PBS does not supply the SMART inventory. Disk identity, health, temperature, and other typed physical-disk facts therefore remain agent-owned, while the PBS source mapping and PBS-parent relationship make the existing disk diff --git a/frontend-modern/browser-tests/pbs-mixed-source-1723.html b/frontend-modern/browser-tests/pbs-mixed-source-1723.html new file mode 100644 index 0000000000..6022382067 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-mixed-source-1723.html @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/frontend-modern/browser-tests/pbs-mixed-source-1723.tsx b/frontend-modern/browser-tests/pbs-mixed-source-1723.tsx new file mode 100644 index 0000000000..c0654d66bf --- /dev/null +++ b/frontend-modern/browser-tests/pbs-mixed-source-1723.tsx @@ -0,0 +1,100 @@ +// Mock-backed current-component browser fixture for the #1723 side-by-side +// PBS API + Agent -> PBS API + PVE API + Agent transition. No live service. +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 pbs = { + id: 'pbs-service', + type: 'pbs', + name: 'backup-connection', + displayName: 'Backup connection', + platformId: 'pbs-service', + platformType: 'proxmox-pbs', + sourceType: 'api', + sources: ['pbs'], + status: 'online', + lastSeen: seen, + metricsTarget: { resourceType: 'agent', resourceId: 'pbs-service' }, + pbs: { + instanceId: 'pbs-service', + hostname: '10.0.0.5', + // PBS API tokens cannot read /nodes; backend reports the corroborated ID. + linkedAgentId: 'agent-uuid', + connectionHealth: 'healthy', + version: '3.2.1', + datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }], + }, +} as Resource; + +const host = { + id: 'agent-uuid', + type: 'agent', + name: 'backup-host.local', + displayName: 'Backup host', + platformId: 'agent-uuid', + platformType: 'proxmox-pbs', + sourceType: 'agent', + sources: ['agent', 'pbs'], + status: 'online', + lastSeen: seen, + agent: { agentId: 'agent-uuid', hostname: 'backup-host.local' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-uuid' }, +} as Resource; + +const pveOnly = { + ...host, + id: 'pve-only', + name: 'backup-connection', + displayName: 'PVE node without Agent', + sources: ['proxmox'], + agent: undefined, + platformData: { sources: ['proxmox'], proxmox: { nodeName: 'backup-connection' } }, + metricsTarget: { resourceType: 'agent', resourceId: 'pve-only' }, +} as Resource; + +const merged = { + ...host, + id: 'pve-merged-host', + displayName: 'PVE plus PBS host', + sources: ['proxmox', 'pbs', 'agent'], + platformData: { + sources: ['proxmox', 'pbs', 'agent'], + agent: host.agent, + proxmox: { nodeName: 'backup-host' }, + }, +} as Resource; + +const Fixture = () => { + const [pveJoined, setPveJoined] = createSignal(false); + const [hostOmitted, setHostOmitted] = createSignal(false); + const servers = createMemo(() => [ + pbs, + ...(pveJoined() ? [pveOnly] : []), + ...(hostOmitted() ? [] : [pveJoined() ? merged : host]), + ]); + return ( +
+

PBS mixed-source History fixture

+
+ + + +
+ {pveJoined() ? 'PBS + PVE + Agent' : 'PBS + Agent'} + +
+ ); +}; + +render(() => , document.getElementById('root') as HTMLElement); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 01e886f36f..497683d9b3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,50 +1,23 @@ { "version": 1, - "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", - "verified_at": "2026-09-29T12:15:52Z", + "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", + "verified_at": "2026-09-29T16:42:43Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/utils/alertDestinationsPresentation.ts", - "frontend-modern/src/utils/licensePresentation.ts", - "frontend-modern/src/utils/relayPresentation.ts", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", - "frontend-modern/src/utils/selfHostedPlans.ts" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/types/resource.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9", - "frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2", - "frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205", - "frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e", - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880", - "frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6", - "frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e", - "frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12", + "frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56" }, "routes": [ - "/settings/system-relay", - "/alerts/notifications", - "/settings/pulse-intelligence/billing/plan", - "/settings/system-general" + "/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)" ], "viewports": [ { - "width": 1024, - "height": 768 + "width": 1365, + "height": 900 }, { "width": 390, @@ -52,16 +25,93 @@ } ], "states": [ - "Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt", - "Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link", - "Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording", - "Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain", - "Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link", - "Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy" + "PBS API + corroborated Agent with token-auth nodeName absent", + "same-host PVE API joined alongside a PVE-only name collision", + "correlated host row temporarily omitted", + "correlated host row restored" ], "interactions": [ - "Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements", - "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", - "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" - ] + "desktop disclosure-button keyboard focus and Enter; phone row click", + "open History and select 24h, 6h, 1h and 12h across topology changes", + "join PVE API, omit host row, restore host row" + ], + "command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.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/core-runtime-8wlxxg93/proof-pbs-1723.cjs", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png" + ], + "notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.", + "prior_verifications": [ + { + "version": 1, + "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", + "verified_at": "2026-09-29T12:15:52Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", + "frontend-modern/src/components/Settings/settingsHeaderMeta.ts", + "frontend-modern/src/components/Settings/settingsNavCatalog.ts", + "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", + "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", + "frontend-modern/src/i18n/messages.de.ts", + "frontend-modern/src/i18n/messages.es.ts", + "frontend-modern/src/i18n/messages.ts", + "frontend-modern/src/utils/alertDestinationsPresentation.ts", + "frontend-modern/src/utils/licensePresentation.ts", + "frontend-modern/src/utils/relayPresentation.ts", + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", + "frontend-modern/src/utils/selfHostedPlans.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755", + "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b", + "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2", + "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86", + "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9", + "frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2", + "frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205", + "frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e", + "frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880", + "frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6", + "frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c", + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e", + "frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970" + }, + "routes": [ + "/settings/system-relay", + "/alerts/notifications", + "/settings/pulse-intelligence/billing/plan", + "/settings/system-general" + ], + "viewports": [ + { + "width": 1024, + "height": 768 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt", + "Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link", + "Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording", + "Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain", + "Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link", + "Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy" + ], + "interactions": [ + "Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements", + "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", + "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" + ] + } + ], + "preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d" } diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 52d0bb97be..6a29b01ea5 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -150,20 +150,44 @@ const correlatedAgentKey = (resource: Resource): string | undefined => { return undefined; }; +// PVE-only nodes are also type 'agent'. They must not masquerade as an agent +// host merely because their hostname matches the PBS service (#1723). +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 (candidate.type !== 'agent' && !isGuestWithAgent(candidate)) return false; + if (!isHostWithAgent(candidate)) return false; for (const token of identityTokens(candidate)) { if (serverTokens.has(token)) return true; } return false; }; +const preferredCorrelatedHost = (matches: readonly Resource[]): Resource | undefined => + matches.find((match) => isGuestWithAgent(match) && match.metricsTarget) ?? + matches.find((match) => match.metricsTarget) ?? + matches[0]; + 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)); @@ -189,11 +213,7 @@ const uniquelyCorrelatedAgent = ( } if (byAgentKey.size !== 1) return undefined; const group = Array.from(byAgentKey.values())[0]; - return ( - group.find((match) => isGuestWithAgent(match) && match.metricsTarget) ?? - group.find((match) => match.metricsTarget) ?? - group[0] - ); + return preferredCorrelatedHost(group); }; // True when the snapshot still offers a host row for this server, even if the @@ -284,6 +304,13 @@ export function buildBackupServerRows( } 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) { + retention?.delete(server.id); + return server; + } const fresh = server.lastSeen - retained.lastSeen <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS; if (fresh && !hasCorrelationCandidate(server, servers)) { diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts index 7c4383c84d..46d366a2e3 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts @@ -146,6 +146,105 @@ const makeCorrelatedAgent = (overrides: Partial = {}): Resource => }) as Resource; describe('buildBackupServerRows PBS host correlation retention', () => { + it('keeps the agent UUID through PBS-only to side-by-side PVE/PBS refreshes without nodeName', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + pbs.name = 'backup-connection'; + pbs.displayName = 'Backup connection'; + pbs.platformId = 'pbs-service'; + pbs.pbs = { + ...pbs.pbs!, + instanceId: 'pbs-service', + hostname: '10.0.0.5', + nodeName: undefined, // API tokens cannot read the PBS /nodes endpoint. + linkedAgentId: 'agent-uuid', // Corroborated by the backend's host interface match. + }; + pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-service' }; + const standalone = makeCorrelatedAgent({ + id: 'agent-uuid', + name: 'backup-host.local', + displayName: 'backup-host.local', + platformId: 'agent-uuid', + agent: { agentId: 'agent-uuid', hostname: 'backup-host.local' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-uuid' }, + }); + const pveMerged = { + ...standalone, + id: 'pve-host', + name: 'PVE display label', + displayName: 'PVE display label', + platformId: 'pve/backup-host', + platformType: 'proxmox-pve', + sources: ['proxmox', 'agent', 'pbs'], + proxmox: { nodeName: 'backup-host' }, + } as Resource; + + expect( + buildBackupServerRows([pbs, standalone], [], retention)[0].resource.metricsTarget, + ).toEqual({ + resourceType: 'agent', + resourceId: 'agent-uuid', + }); + expect( + buildBackupServerRows([pbs, pveMerged], [], retention)[0].resource.metricsTarget, + ).toEqual({ + resourceType: 'agent', + resourceId: 'agent-uuid', + }); + expect( + buildBackupServerRows([pbs, pveMerged], [], retention)[0].resource.metricsTarget, + ).toEqual({ + resourceType: 'agent', + resourceId: 'agent-uuid', + }); + }); + + it('does not replace an explicit PBS host link with a same-name different agent', () => { + const pbs = makeCorrelatablePbs(); + pbs.pbs = { ...pbs.pbs!, linkedAgentId: 'agent-uuid' }; + const impostor = makeCorrelatedAgent({ + id: 'agent-other', + agent: { agentId: 'agent-other', hostname: 'proxback-vm' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-other' }, + }); + expect(buildBackupServerRows([pbs, impostor])[0].resource.metricsTarget?.resourceId).toBe( + 'proxback', + ); + }); + + it('does not mistake a PVE-only node for a second host agent by name', () => { + const pbs = makeCorrelatablePbs(); + const agent = makeCorrelatedAgent(); + const pveOnly = { + ...agent, + id: 'pve-only', + sources: ['proxmox'], + agent: undefined, + platformData: { sources: ['proxmox'], proxmox: { nodeName: 'proxback' } }, + metricsTarget: { resourceType: 'node', resourceId: 'pve-only' }, + } as Resource; + + expect(buildBackupServerRows([pbs, agent, pveOnly])[0].resource.metricsTarget?.resourceId).toBe( + 'agent-proxback', + ); + }); + + it('invalidates retained history when the corroborated agent identity changes', () => { + const retention = createPbsCorrelationRetention(); + const pbs = makeCorrelatablePbs(); + pbs.pbs = { ...pbs.pbs!, linkedAgentId: 'agent-proxback' }; + buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention); + + const replacement = { + ...pbs, + pbs: { ...pbs.pbs!, linkedAgentId: 'agent-replacement' }, + } as Resource; + expect( + buildBackupServerRows([replacement], [], 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(); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 6b51e5b822..6011425fef 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -4,6 +4,7 @@ import { createSignal, type JSX } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable'; +import { buildBackupServerRows } from '../ProxmoxBackupServersTable'; import proxmoxBackupServersTableSource from '../ProxmoxBackupServersTable.tsx?raw'; import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw'; import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw'; @@ -212,6 +213,44 @@ beforeEach(() => { }); describe('ProxmoxBackupsTable', () => { + it('uses a corroborated PBS host link for Backups History despite a PVE-only name collision', () => { + const pbs = { + ...pbsServerResource, + name: 'backup-connection', + displayName: 'Backup connection', + metricsTarget: { resourceType: 'agent', resourceId: 'pbs-service' }, + pbs: { + ...pbsServerResource.pbs!, + hostname: '10.0.0.5', + nodeName: undefined, + linkedAgentId: 'agent-uuid', + }, + } as Resource; + const host = { + ...workloadResource, + id: 'host-merged-with-pve', + type: 'agent', + name: 'different-hostname', + sources: ['proxmox', 'agent'], + agent: { agentId: 'agent-uuid', hostname: 'different-hostname' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-uuid' }, + } as Resource; + const pveOnly = { + ...host, + id: 'pve-only', + name: 'backup-connection', + agent: undefined, + sources: ['proxmox'], + metricsTarget: { resourceType: 'agent', resourceId: 'pve-only' }, + } as Resource; + + const row = buildBackupServerRows([pbs, pveOnly, host])[0]; + expect(row.resource.id).toBe(pbs.id); + expect(row.resource.pbs?.linkedAgentId).toBe('agent-uuid'); + expect(row.resource.metricsTarget).toEqual({ resourceType: 'agent', resourceId: 'agent-uuid' }); + expect(row.resource.agent?.agentId).toBe('agent-uuid'); + }); + it('shows Checking until the canonical posture request resolves', async () => { mockBackupAPIs(); let resolvePosture: ((value: unknown) => void) | undefined; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index cd4db73cf6..c79d1900d0 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -473,6 +473,7 @@ export interface ResourcePBSMeta { instanceId?: string; hostname?: string; nodeName?: string; + linkedAgentId?: string; version?: string; uptimeSeconds?: number; datastoreCount?: number; diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index bd3ed50a21..3418d614e1 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -3,6 +3,7 @@ package unifiedresources import ( "encoding/json" "reflect" + "strings" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -105,6 +106,38 @@ func TestDockerCollectionModeDoesNotChangeCanonicalIdentity(t *testing.T) { } } +func TestPBSLinkedAgentDoesNotChangeServiceCanonicalIdentityOrTarget(t *testing.T) { + resource := Resource{ + ID: "pbs-service", + Type: ResourceTypePBS, + Name: "Backup connection", + PBS: &PBSData{InstanceID: "pbs-service", Hostname: "10.0.0.5"}, + MetricsTarget: &MetricsTarget{ResourceType: "agent", ResourceID: "pbs-service"}, + } + RefreshCanonicalIdentity(&resource) + if resource.Canonical == nil { + t.Fatal("PBS canonical identity is nil") + } + want := *resource.Canonical + wantTarget := *resource.MetricsTarget + + resource.PBS.LinkedAgentID = "agent-uuid" + RefreshCanonicalIdentity(&resource) + if resource.Canonical == nil || !reflect.DeepEqual(*resource.Canonical, want) { + t.Fatalf("presentation-only host link changed PBS service identity: got %+v, want %+v", resource.Canonical, want) + } + if resource.MetricsTarget == nil || *resource.MetricsTarget != wantTarget { + t.Fatalf("presentation-only host link changed PBS service target: got %+v, want %+v", resource.MetricsTarget, wantTarget) + } + encoded, err := json.Marshal(resource) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encoded), `"linkedAgentId":"agent-uuid"`) { + t.Fatalf("PBS host link missing from resource JSON: %s", encoded) + } +} + func TestRefreshCanonicalIdentityKeepsProxmoxPresentationSeparateFromNativeAliases(t *testing.T) { resource := Resource{ ID: "production-pve1", diff --git a/internal/unifiedresources/pbs_host_disks.go b/internal/unifiedresources/pbs_host_disks.go index be1c61a817..cdc4a3202d 100644 --- a/internal/unifiedresources/pbs_host_disks.go +++ b/internal/unifiedresources/pbs_host_disks.go @@ -61,6 +61,16 @@ func (rr *ResourceRegistry) associatePBSHostAgentResources( } hostCanonicalID := rr.bySource[SourceAgent][normalizeSourceID(host.ID)] + // Expose the already-corroborated source-native agent identity on the PBS + // service. The service and host retain separate resources and metric targets; + // this only lets consumers select the host series when names/IP aliases do + // not line up (including token-auth PBS connections without nodeName). + if pbsResource := rr.resources[pbsParentID]; pbsResource != nil && pbsResource.PBS != nil { + if hostResource := rr.resources[hostCanonicalID]; hostResource != nil && + hostResource.Agent != nil && strings.TrimSpace(hostResource.Agent.AgentID) == strings.TrimSpace(host.ID) { + pbsResource.PBS.LinkedAgentID = strings.TrimSpace(host.ID) + } + } attach( fmt.Sprintf("%s/agent:%s", pbsSourceID, strings.TrimSpace(host.ID)), hostCanonicalID, diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 44252ad76e..59a9957c7f 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -368,6 +368,64 @@ func TestIngestSnapshotAssociatesPBSHostAgentByReportedNodeName(t *testing.T) { if !containsDataSource(hostResource.Sources, SourcePBS) { t.Fatalf("PBS host sources = %v, want PBS membership via reported node name", hostResource.Sources) } + if pbsResource.PBS.LinkedAgentID != "agent-pbs-1" { + t.Fatalf("PBS linked agent = %q, want agent-pbs-1", pbsResource.PBS.LinkedAgentID) + } +} + +// A PBS API token cannot read GET /nodes, so its poll has no nodeName. The +// unique endpoint/interface match still identifies the host, even after PVE +// is added on that same machine and the agent folds into its PVE node view. +func TestIngestSnapshotPBSHostLinkSurvivesSideBySidePVEWithToken(t *testing.T) { + now := time.Now().UTC() + for _, withPVE := range []bool{false, true} { + snapshot := models.StateSnapshot{ + Hosts: []models.Host{{ + ID: "agent-uuid", Hostname: "backup-host.local", ReportIP: "10.0.0.5", + Status: "online", LastSeen: now, + NetworkInterfaces: []models.HostNetworkInterface{{ + Name: "eth0", Addresses: []string{"10.0.0.5/24"}, + }}, + }}, + PBSInstances: []models.PBSInstance{{ + ID: "pbs-service", Name: "backup-connection", Host: "https://10.0.0.5:8007", + Status: "online", LastSeen: now, + }}, + } + if withPVE { + snapshot.Nodes = []models.Node{{ + ID: "pve/backup-host", Name: "backup-host", DisplayName: "PVE display label", + Instance: "pve-connection", Host: "https://10.0.0.5:8006", + LinkedAgentID: "agent-uuid", Status: "online", LastSeen: now, + }} + snapshot.Hosts[0].LinkedNodeID = "pve/backup-host" + } + registry := NewRegistry(nil) + registry.IngestSnapshot(snapshot) + var pbs *Resource + var host *Resource + for _, resource := range registry.ListForPresentation() { + resource := resource + if resource.Type == ResourceTypePBS { + pbs = &resource + } + if resource.Agent != nil && resource.Agent.AgentID == "agent-uuid" { + host = &resource + } + } + if pbs == nil || host == nil { + t.Fatalf("withPVE=%t: missing PBS or host resource: pbs=%+v host=%+v", withPVE, pbs, host) + } + if pbs.PBS == nil || pbs.PBS.LinkedAgentID != "agent-uuid" { + t.Fatalf("withPVE=%t: PBS linked agent = %+v, want agent-uuid", withPVE, pbs.PBS) + } + if hostTarget := registry.MetricsTarget(host.ID); hostTarget == nil || *hostTarget != (MetricsTarget{ResourceType: "agent", ResourceID: "agent-uuid"}) { + t.Fatalf("withPVE=%t: host target = %+v, want agent UUID", withPVE, hostTarget) + } + if withPVE && host.Proxmox == nil { + t.Fatalf("PVE+PBS host lost PVE facet: %+v", host) + } + } } func containsPlatformScope(values []string, expected string) bool { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 7722359956..6fdff83064 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1246,9 +1246,13 @@ type DockerData struct { // // NOTE: Some tools need per-datastore details; those are exposed via Datastores. type PBSData struct { - InstanceID string `json:"instanceId,omitempty"` - Hostname string `json:"hostname,omitempty"` - NodeName string `json:"nodeName,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + Hostname string `json:"hostname,omitempty"` + NodeName string `json:"nodeName,omitempty"` + // LinkedAgentID is set only when the PBS connection uniquely corroborates + // a host agent. API-token connections cannot read the PBS node-name endpoint, + // so consumers must not rely on a matching display name for host history. + LinkedAgentID string `json:"linkedAgentId,omitempty"` HostURL string `json:"hostUrl,omitempty"` GuestURL string `json:"guestUrl,omitempty"` Version string `json:"version,omitempty"`