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"/<node.ID>) 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.
This commit is contained in:
rcourtman
2026-06-10 18:13:54 +01:00
parent 1e0ea20cd5
commit 8ce834cc59
3 changed files with 103 additions and 5 deletions
+10 -3
View File
@@ -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}
@@ -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 {
+68 -2
View File
@@ -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{