diff --git a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx index 414a5a683e..22b50c3ead 100644 --- a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx +++ b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx @@ -29,7 +29,11 @@ const pbs = { memory: { current: 20.3, total: 8000, used: 1624, free: 6376 }, pbs: { instanceId: 'proxback', - hostname: 'proxback-vm', + // The connection is configured by an address the agent never reports, so + // the reported node name below is the only machine identity that links the + // PBS service row to its host agent (#1723). + hostname: '10.0.0.5', + nodeName: 'proxback-vm', version: '3.2.1', connectionHealth: 'healthy', datastores: [ @@ -42,7 +46,12 @@ const pbs = { discoveryTarget: { resourceType: 'agent', resourceId: 'pbs-1' }, platformData: { sources: ['pbs'], - pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 2 }, + pbs: { + instanceId: 'proxback', + hostname: '10.0.0.5', + nodeName: 'proxback-vm', + datastoreCount: 2, + }, }, } as unknown as Resource; diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5a0c6ad000..43b2788a78 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,13 +1,15 @@ { "version": 1, - "base_sha": "92edc056627a88bbdb11a8f6d6f95aacd061607a", - "verified_at": "2026-09-23T09:22:16Z", + "base_sha": "e994c41c2067dd5aedd7400306832a813fb575ea", + "verified_at": "2026-09-23T11:26:00Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/types/resource.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b19198cb055e5f0f107c2d6749d6def719bd84310de585e3b6a349fa22fac3c1" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "f6b50e71870ca4a3e0dcfa3e47413542e8444296bc68481c60e15d1a5d921f9f", + "frontend-modern/src/types/resource.ts": "db2509b73b2ed550ea1890aaef34e2a3dfe7d500aa193586562f9e20b95283ab" }, "routes": [ "/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)", @@ -28,17 +30,18 @@ } ], "states": [ + "PBS connection configured by 10.0.0.5 and linked to the host agent only by the reported pbs.nodeName, across pbs-only, side-by-side and PVE-guest topologies", "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, switch between datastore rows and select History; assert the chart paths render against the host metrics target", + "Expand the Proxmox Backups server row, switch between datastore rows and select History; assert the chart paths render against the host metrics target even though the configured endpoint is not a machine-identity token", "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-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." + "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 reported-node-name host correlation and its retention across a transient host-row omission, not backend persistence or installed release behaviour. The recorded history requests target agent/agent-proxback and vm/proxmox:100 and never the PBS service target agent/pbs-1. The same check fails on the pre-fix source: the drawer falls back to the PBS service key when the endpoint is not a machine-identity token, and omitting the host row flips the Discovery and Metrics Target rows to agent:pbs-1." } diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 9ae8e84670..52d0bb97be 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -110,6 +110,12 @@ const identityValues = (resource: Resource): Array => [ ...(resource.identity?.ips ?? []), resource.agent?.hostname, resource.pbs?.hostname, + // The node hostname PBS reports about itself is machine identity. The + // connection may be configured by an IP or DNS alias the agent never + // reports, so without this the host correlation can drop out between + // snapshots and the drawer falls back to the service history target + // (#1723). + resource.pbs?.nodeName, resource.pbs?.instanceId, resource.platformId, resource.name, 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 b40426127c..52a65298c5 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx @@ -224,6 +224,58 @@ describe('ProxmoxBackupServersTable details', () => { }, ); + it('correlates a PBS connection configured by IP to its agent via the reported node name', () => { + const pbs = makePbsResource(); + pbs.sources = ['pbs']; + pbs.agent = undefined; + pbs.name = 'backup-connection'; + pbs.displayName = 'Backup connection'; + pbs.platformId = 'pbs-1'; + pbs.pbs = { + ...pbs.pbs!, + instanceId: 'pbs-1', + hostname: '10.0.0.5', + nodeName: 'pbs-one.local', + }; + pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-1' }; + pbs.platformData = { + sources: ['pbs'], + pbs: { + instanceId: 'pbs-1', + hostname: '10.0.0.5', + nodeName: 'pbs-one.local', + datastoreCount: 1, + }, + }; + const agent = { + id: 'agent-host-1', + type: 'agent', + name: 'pbs-one.local', + displayName: 'PBS host', + platformId: 'agent-host-1', + platformType: 'proxmox-pbs', + sourceType: 'hybrid', + sources: ['agent', 'pbs'], + status: 'online', + lastSeen: pbs.lastSeen + 1_000, + agent: { agentId: 'agent-pbs-1', hostname: 'pbs-one.local', osName: 'Debian GNU/Linux' }, + identity: { hostname: 'pbs-one.local', ips: ['10.9.9.9'] }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' }, + platformData: { + sources: ['agent', 'pbs'], + agent: { agentId: 'agent-pbs-1', hostname: 'pbs-one.local' }, + }, + } as Resource; + + render(() => ); + fireEvent.click(screen.getByRole('button', { name: 'Expand details for backup-connection' })); + + const detail = screen.getByTestId('pbs-resource-detail'); + expect(detail).toHaveAttribute('data-agent-id', 'agent-pbs-1'); + expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1'); + expect(detail).toHaveAttribute('data-metrics-resource-type', 'agent'); + }); + it('reuses the PVE guest when the same agent is listed as both guest and standalone host', () => { const pbs = makePbsResource(); pbs.sources = ['pbs']; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index cb60a93299..cd4db73cf6 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -472,6 +472,7 @@ export interface ResourcePBSDatastore { export interface ResourcePBSMeta { instanceId?: string; hostname?: string; + nodeName?: string; version?: string; uptimeSeconds?: number; datastoreCount?: number; diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index dbfb71cab4..7e4b9e79a5 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1507,6 +1507,7 @@ func resourceFromPBSInstance(instance models.PBSInstance) (Resource, ResourceIde PBS: &PBSData{ InstanceID: instance.ID, Hostname: extractHostname(instance.Host), + NodeName: strings.TrimSpace(instance.NodeName), HostURL: instance.Host, GuestURL: instance.GuestURL, Version: instance.Version, diff --git a/internal/unifiedresources/pbs_host_disks.go b/internal/unifiedresources/pbs_host_disks.go index ac2c95e67a..be1c61a817 100644 --- a/internal/unifiedresources/pbs_host_disks.go +++ b/internal/unifiedresources/pbs_host_disks.go @@ -98,6 +98,13 @@ func pbsInstanceCorroboratesHost(instance models.PBSInstance, host models.Host) if hostName == "" { return false } + // The node hostname the PBS API reports about itself is machine identity. + // It is the strongest evidence when the connection is configured by IP or + // a DNS alias the agent never reports, which the connection label and the + // configured endpoint cannot corroborate on their own (#1723). + if nodeName := NormalizeHostname(instance.NodeName); nodeName != "" && nodeName == hostName { + return true + } if instanceName := NormalizeHostname(instance.Name); instanceName != "" && instanceName == hostName { return true } diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 778aaf3779..44252ad76e 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -309,6 +309,67 @@ func TestIngestSnapshotSkipsAmbiguousPBSHostAgentAssociation(t *testing.T) { } } +// A PBS connection is often configured by IP or a DNS alias the agent never +// reports. The node hostname PBS reports about itself is then the only machine +// identity linking the host agent to the connection (#1723). +func TestIngestSnapshotAssociatesPBSHostAgentByReportedNodeName(t *testing.T) { + now := time.Now().UTC() + registry := NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "agent-pbs-1", + Hostname: "pbs-one.local", + MachineID: "machine-pbs-1", + ReportIP: "10.9.9.9", + Status: "online", + LastSeen: now, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{{Device: "/dev/sda", Serial: "PBS-DISK-1"}}, + }, + }, + }, + PBSInstances: []models.PBSInstance{ + { + ID: "pbs-1", + Name: "backup-connection", // connection label, not the machine + Host: "https://10.0.0.5:8007", // endpoint the agent never reports + // Machine identity the PBS node reports about itself. + NodeName: "pbs-one.local", + Status: "online", + LastSeen: now, + }, + }, + }) + + var pbsResource *Resource + var hostResource *Resource + for _, resource := range registry.List() { + switch resource.Type { + case ResourceTypePBS: + copy := resource + pbsResource = © + case ResourceTypeAgent: + if resource.Agent != nil && resource.Agent.AgentID == "agent-pbs-1" { + copy := resource + hostResource = © + } + } + } + if pbsResource == nil || pbsResource.PBS == nil { + t.Fatalf("expected PBS resource with payload, got %+v", pbsResource) + } + if pbsResource.PBS.NodeName != "pbs-one.local" { + t.Fatalf("PBS nodeName = %q, want reported machine hostname", pbsResource.PBS.NodeName) + } + if hostResource == nil { + t.Fatal("expected host agent resource") + } + if !containsDataSource(hostResource.Sources, SourcePBS) { + t.Fatalf("PBS host sources = %v, want PBS membership via reported node name", hostResource.Sources) + } +} + func containsPlatformScope(values []string, expected string) bool { for _, value := range values { if value == expected { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 7e4261e500..7722359956 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1248,6 +1248,7 @@ type DockerData struct { type PBSData struct { InstanceID string `json:"instanceId,omitempty"` Hostname string `json:"hostname,omitempty"` + NodeName string `json:"nodeName,omitempty"` HostURL string `json:"hostUrl,omitempty"` GuestURL string `json:"guestUrl,omitempty"` Version string `json:"version,omitempty"`