From 8ce834cc5900a431b5d91d545540a25c875850be Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Jun 2026 18:13:54 +0100 Subject: [PATCH] fix(resources): resolve agent metrics targets to the host agent ID for merged hosts Real-mode agent host metrics are written to the metrics store keyed by host.ID (monitor_agents.go writes "agent"/host.ID, pinned by a canonical guardrail), and ingestHost registers that same ID as the SourceAgent mapping. But BuildMetricsTarget preferred the Proxmox/VMware source ID for agent resources, so a host that is also a Proxmox node (delly, minipc, pi) advertised an "agent"-typed metrics target (e.g. "homelab-delly") that nothing ever writes. Every reader that trusts the registry target queried zero rows: performance reports resolved via MetricsResourceID rendered "Data Points: 0", and the resource drawer history endpoint fell back to in-memory node history (losing temperature and disk I/O series the agent records). Flip the priority: the agent source wins for agent resources, platform sources (Proxmox, VMware, TrueNAS, Docker) remain fallbacks for hosts without a reporting agent. This matches the write path exactly - the bulk charts feed (hostAgentChartRequest) already preferred the agent ID, and pure-agent hosts already resolved to it, so merged hosts simply become consistent. No history is orphaned: "agent" store rows were always keyed by host.ID, and node-poller rows ("node"/) were never reachable through the "agent"-typed target. Verified live: all five real agents now resolve metricsTarget to their agent UUID, /api/metrics-store/history returns store rows for the new target, and the delly performance report renders 312 data points where it previously rendered zero. Regression tests: BuildMetricsTarget prefers the agent source for merged-source resources, and a registry ingest test asserts the merged node+agent resource resolves its target to host.ID - the agent store write key. --- internal/unifiedresources/metrics_targets.go | 13 +++- .../unifiedresources/metrics_targets_test.go | 25 +++++++ internal/unifiedresources/registry_test.go | 70 ++++++++++++++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/internal/unifiedresources/metrics_targets.go b/internal/unifiedresources/metrics_targets.go index 2e9dd3cf3..5865ccc48 100644 --- a/internal/unifiedresources/metrics_targets.go +++ b/internal/unifiedresources/metrics_targets.go @@ -20,15 +20,22 @@ func BuildMetricsTarget(resource Resource, sourceTargets []SourceTarget) *Metric switch CanonicalResourceType(resource.Type) { case ResourceTypeAgent: + // The agent source must win over platform sources (Proxmox/VMware): + // real-mode agent metrics are written under the host agent ID + // (monitor_agents.go writes "agent"/host.ID, the same ID ingestHost + // registers for SourceAgent), so a merged node+agent resource must + // advertise that key. Preferring the platform source ID here would + // point store readers (reports, drawer history) at an "agent"-typed + // key nothing ever writes. + if st, ok := bySource[SourceAgent]; ok { + return &MetricsTarget{ResourceType: "agent", ResourceID: st.SourceID} + } if st, ok := bySource[SourceProxmox]; ok { return &MetricsTarget{ResourceType: "agent", ResourceID: st.SourceID} } if st, ok := bySource[SourceVMware]; ok { return &MetricsTarget{ResourceType: "agent", ResourceID: st.SourceID} } - if st, ok := bySource[SourceAgent]; ok { - return &MetricsTarget{ResourceType: "agent", ResourceID: st.SourceID} - } if st, ok := bySource[SourceTrueNAS]; ok { if resourceID := canonicalAgentMetricID(st.SourceID); resourceID != "" { return &MetricsTarget{ResourceType: "agent", ResourceID: resourceID} diff --git a/internal/unifiedresources/metrics_targets_test.go b/internal/unifiedresources/metrics_targets_test.go index d9180196d..16af9e1ad 100644 --- a/internal/unifiedresources/metrics_targets_test.go +++ b/internal/unifiedresources/metrics_targets_test.go @@ -76,6 +76,31 @@ func TestBuildMetricsTarget_UsesCanonicalAgentTypeForInfrastructureFamilies(t *t } } +func TestBuildMetricsTarget_AgentSourceWinsForMergedHostResources(t *testing.T) { + // Real-mode agent metrics are stored under the host agent ID (the + // monitor_agents.go writer keys "agent" rows by host.ID, which is also + // the SourceAgent ingest key). A host that is simultaneously a Proxmox + // node (or VMware host) must still resolve its metrics target to the + // agent key, or store readers query an ID nothing writes. + target := BuildMetricsTarget( + Resource{Type: ResourceTypeAgent}, + []SourceTarget{ + {Source: SourceProxmox, SourceID: "homelab-delly"}, + {Source: SourceVMware, SourceID: "vc-1:host:host-101"}, + {Source: SourceAgent, SourceID: "7d465a78-80b8-4255-999c-88224ae57b4f"}, + }, + ) + if target == nil { + t.Fatal("BuildMetricsTarget() returned nil") + } + if target.ResourceType != "agent" { + t.Fatalf("ResourceType = %q, want agent", target.ResourceType) + } + if target.ResourceID != "7d465a78-80b8-4255-999c-88224ae57b4f" { + t.Fatalf("ResourceID = %q, want the agent source ID (the metrics-store write key)", target.ResourceID) + } +} + func TestMetricsFromUnraidDiskCapacityUsesNativeCapacityFields(t *testing.T) { metrics := metricsFromUnraidDiskCapacity(1_000, 650, 65) if metrics == nil || metrics.Disk == nil { diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index de852a8aa..e43c74826 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -357,8 +357,10 @@ func TestResourceRegistry_IngestResourcesRebuildsSourceMappingsForMetricsTargets if target == nil { t.Fatal("expected seeded unified agent to rebuild a metrics target") } - if target.ResourceType != "agent" || target.ResourceID != "homelab-delly" { - t.Fatalf("metrics target = %+v, want agent/homelab-delly", target) + // The agent source ID wins over the proxmox node ID: it is the host.ID + // key the agent metrics writer stores rows under. + if target.ResourceType != "agent" || target.ResourceID != "agent-source-delly" { + t.Fatalf("metrics target = %+v, want agent/agent-source-delly", target) } targets := rr.SourceTargets("agent-dashboard-1") @@ -1659,6 +1661,70 @@ func TestResourceRegistry_IngestSnapshotParentsClusterNamedProxmoxGuestsToMerged } } +func TestResourceRegistry_MergedNodeAgentMetricsTargetMatchesAgentStoreWriteKey(t *testing.T) { + // Real-mode agent metrics land in the metrics store keyed by host.ID + // (monitor_agents.go: m.metricsStore.Write("agent", host.ID, ...), the + // write key is pinned by a canonical guardrail in internal/monitoring). + // The registry's metrics target for the merged node+agent resource must + // resolve to that same key, or every store reader that trusts the + // target — performance reports via MetricsResourceID, the drawer + // history endpoint — queries an ID with zero rows. + rr := NewRegistry(nil) + now := time.Date(2026, 6, 10, 10, 0, 0, 0, time.UTC) + + rr.IngestSnapshot(models.StateSnapshot{ + Nodes: []models.Node{ + { + ID: "homelab-delly", + Name: "delly", + Instance: "delly", + ClusterName: "homelab", + IsClusterMember: true, + Host: "https://10.0.0.9:8006", + LinkedAgentID: "host-1", + Status: "online", + LastSeen: now, + }, + }, + Hosts: []models.Host{ + { + ID: "host-1", + Hostname: "delly.local", + MachineID: "machine-delly", + ReportIP: "10.0.0.9", + Status: "online", + LastSeen: now, + LinkedNodeID: "homelab-delly", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "eth0", MAC: "00:11:22:33:44:66", Addresses: []string{"10.0.0.9/24"}}, + }, + }, + }, + }) + + agents := rr.ListByType(ResourceTypeAgent) + if len(agents) != 1 { + t.Fatalf("expected 1 merged delly resource, got %d", len(agents)) + } + resource := agents[0] + + targets := rr.SourceTargets(resource.ID) + if len(targets) != 2 { + t.Fatalf("expected merged proxmox+agent source targets, got %+v", targets) + } + + target := BuildMetricsTargetForRegistry(rr, resource.ID) + if target == nil { + t.Fatal("BuildMetricsTargetForRegistry() returned nil") + } + if target.ResourceType != "agent" { + t.Fatalf("ResourceType = %q, want agent", target.ResourceType) + } + if target.ResourceID != "host-1" { + t.Fatalf("ResourceID = %q, want host-1 (the host.ID key the agent metrics writer uses)", target.ResourceID) + } +} + func TestResourceRegistry_ManualNodeMergeRewritesProxmoxGuestParentAndActionAgent(t *testing.T) { store := NewMemoryStore() if err := store.AddLink(ResourceLink{