fix(unifiedresources): correlate PBS host agent by reported node name

The PBS API reports the node hostname about itself, but host-agent correlation only consulted the configured connection label and endpoint. When a connection is configured by IP or a DNS alias the agent never reports, the host agent could not be linked to its PBS connection, so the Backups drawer lost its persisted host history target between snapshots and fell back to the PBS service key, which has no host series (#1723).

Use the reported node name in pbsInstanceCorroboratesHost and expose it on the PBS resource (pbs.nodeName). The Proxmox Backups identity correlation now includes it so the host row stays correlated when the configured endpoint is not a machine-identity token.

Rebuilt on the current canonical pulse tip e994c41c20, which already carries the web-product retention change (5d831b8765). The offline browser fixture now combines both failure modes: the connection is configured by 10.0.0.5 and linked only by the reported pbs.nodeName, and a live refresh can transiently omit the correlated host row. The check passes 12/12 across pbs-only, side-by-side and guest topologies, stable and reordered snapshots, at 1280x900 and 390x844, and never requests the PBS service target.

Contract-Neutral: Behavioral machine-identity correlation fix plus an additive optional pbs.nodeName field; no breaking contract, public API removal, or canonical-ownership change.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-23 11:29:27 +01:00
parent e994c41c20
commit e137630fdf
9 changed files with 149 additions and 8 deletions
@@ -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;
+9 -6
View File
@@ -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."
}
@@ -110,6 +110,12 @@ const identityValues = (resource: Resource): Array<string | undefined> => [
...(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,
@@ -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(() => <ProxmoxBackupServersTable servers={[pbs, agent]} />);
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'];
+1
View File
@@ -472,6 +472,7 @@ export interface ResourcePBSDatastore {
export interface ResourcePBSMeta {
instanceId?: string;
hostname?: string;
nodeName?: string;
version?: string;
uptimeSeconds?: number;
datastoreCount?: number;
+1
View File
@@ -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,
@@ -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
}
@@ -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 = &copy
case ResourceTypeAgent:
if resource.Agent != nil && resource.Agent.AgentID == "agent-pbs-1" {
copy := resource
hostResource = &copy
}
}
}
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 {
+1
View File
@@ -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"`