Fix storage trend metrics targets

This commit is contained in:
rcourtman 2026-05-14 22:12:16 +01:00
parent fbcb77d635
commit a36bb254d5
4 changed files with 163 additions and 5 deletions

View file

@ -196,7 +196,13 @@ carry the canonical `Resource.Sources` array onto `ResourceFrontend` and
source/facet instead of treating the `storage` resource type as Proxmox by
default. Appliance presentation details such as Unraid array identity may remain
inside storage metadata, but agent-backed storage must stay canonical
`platformType=agent`.
`platformType=agent`. That same broadcast resource contract owns resolved
metrics targets. Monitoring must enrich broadcast/state `ResourceFrontend`
payloads from the active metrics-target read-state before serialization so
`/api/state` and websocket consumers use the same canonical metrics history IDs
as `/api/resources`; storage resources must not fall back to generated
`Resource.ID` values when the unified resource registry can resolve a
source-owned `storage` target.
Unraid ingest must preserve the agent's native disk topology fields through the
monitoring model and read-state projection. `internal/monitoring/monitor_agents.go`
and `internal/monitoring/monitor.go` must carry model, transport, filesystem,

View file

@ -222,10 +222,12 @@ func TestBroadcastResourceProjectionCoalescesSplitHostIdentities(t *testing.T) {
source := string(data)
for _, snippet := range []string{
"metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)",
"broadcastResources := coalesceBroadcastResources(unifiedView.resources)",
"frontendState.Resources = convertResourcesForBroadcast(broadcastResources)",
"frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)",
"frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)",
"func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource {",
"func attachBroadcastMetricsTargets(",
"func shouldMergeBroadcastHostResources(left, right unifiedresources.Resource) bool {",
"broadcastHasSource(sources, unifiedresources.SourceAgent) && broadcastHasRuntimePlatformSource(sources)",
} {

View file

@ -3726,8 +3726,9 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS
}
}
unifiedView := m.currentUnifiedStateView()
metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)
broadcastResources := coalesceBroadcastResources(unifiedView.resources)
frontendState.Resources = convertResourcesForBroadcast(broadcastResources)
frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)
frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)
if !unifiedView.freshness.IsZero() {
frontendState.LastUpdate = unifiedView.freshness.UnixMilli()
@ -4898,7 +4899,13 @@ func (m *Monitor) getUnifiedResourcesForBroadcast() []unifiedresources.Resource
// getResourcesForBroadcast retrieves all resources from the store and converts
// them to frontend format.
func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
return convertResourcesForBroadcast(m.getUnifiedResourcesForBroadcast())
m.mu.RLock()
store := m.resourceStore
m.mu.RUnlock()
return convertResourcesForBroadcast(
m.getUnifiedResourcesForBroadcast(),
broadcastMetricsTargetResolver(store),
)
}
func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
@ -5215,10 +5222,17 @@ func betterBroadcastStatus(left, right unifiedresources.ResourceStatus) unifiedr
}
// convertResourcesForBroadcast converts unified resources into the frontend payload shape.
func convertResourcesForBroadcast(allResources []unifiedresources.Resource) []models.ResourceFrontend {
func convertResourcesForBroadcast(
allResources []unifiedresources.Resource,
metricsTargetResolvers ...MetricsTargetResourceStore,
) []models.ResourceFrontend {
if len(allResources) == 0 {
return []models.ResourceFrontend{}
}
allResources = attachBroadcastMetricsTargets(
allResources,
firstBroadcastMetricsTargetResolver(metricsTargetResolvers),
)
allResources = coalesceBroadcastResources(allResources)
type broadcastResource struct {
input models.ResourceConvertInput
@ -5254,6 +5268,62 @@ func convertResourcesForBroadcast(allResources []unifiedresources.Resource) []mo
return result
}
func broadcastMetricsTargetResolver(source interface{}) MetricsTargetResourceStore {
resolver, ok := source.(MetricsTargetResourceStore)
if !ok {
return nil
}
return resolver
}
func firstBroadcastMetricsTargetResolver(
resolvers []MetricsTargetResourceStore,
) MetricsTargetResourceStore {
for _, resolver := range resolvers {
if resolver != nil {
return resolver
}
}
return nil
}
func attachBroadcastMetricsTargets(
resources []unifiedresources.Resource,
resolver MetricsTargetResourceStore,
) []unifiedresources.Resource {
if len(resources) == 0 || resolver == nil {
return resources
}
out := make([]unifiedresources.Resource, len(resources))
for i, resource := range resources {
if resource.MetricsTarget == nil {
resource.MetricsTarget = validBroadcastMetricsTarget(
resolver.MetricsTargetForResource(resource.ID),
)
}
out[i] = resource
}
return out
}
func validBroadcastMetricsTarget(
target *unifiedresources.MetricsTarget,
) *unifiedresources.MetricsTarget {
if target == nil {
return nil
}
resourceType := strings.TrimSpace(target.ResourceType)
resourceID := strings.TrimSpace(target.ResourceID)
if resourceType == "" || resourceID == "" {
return nil
}
return &unifiedresources.MetricsTarget{
ResourceType: resourceType,
ResourceID: resourceID,
}
}
func monitorResourceToConvertInput(resource unifiedresources.Resource) models.ResourceConvertInput {
resourceType := monitorFrontendResourceType(resource)
if resource.DiscoveryTarget == nil {

View file

@ -152,6 +152,86 @@ func TestConvertResourcesForBroadcastCoalescesSplitHostResources(t *testing.T) {
}
}
func TestConvertResourcesForBroadcastAttachesResolvedStorageMetricsTarget(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 45, 0, 0, time.UTC)
agentID := "de6d3fee-2595-6c2b-6b08-43db6b0ab427"
storageID := "storage-2642e9fca16b2f87"
parentID := agentID
total := int64(24_000_000_000_000)
used := int64(16_900_000_000_000)
registry := unifiedresources.NewRegistry(nil)
registry.IngestResources([]unifiedresources.Resource{
{
ID: agentID,
Type: unifiedresources.ResourceTypeAgent,
Name: "Tower",
Status: unifiedresources.StatusOnline,
LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent},
Agent: &unifiedresources.AgentData{
AgentID: agentID,
Hostname: "Tower",
},
},
{
ID: storageID,
Type: unifiedresources.ResourceTypeStorage,
Name: "Tower Array",
Status: unifiedresources.StatusWarning,
LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent},
ParentID: &parentID,
Metrics: &unifiedresources.ResourceMetrics{
Disk: &unifiedresources.MetricValue{
Used: &used,
Total: &total,
Percent: 70.4,
Unit: "bytes",
Source: unifiedresources.SourceAgent,
},
},
Storage: &unifiedresources.StorageMeta{
Type: "unraid-array",
Platform: "unraid",
Topology: "array",
},
},
})
adapter := unifiedresources.NewMonitorAdapter(registry)
frontend := convertResourcesForBroadcast(adapter.GetAll(), adapter)
var storagePayload struct {
ID string `json:"id"`
MetricsTarget struct {
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId"`
} `json:"metricsTarget"`
}
for _, resource := range frontend {
if resource.ID != storageID {
continue
}
encoded, err := json.Marshal(resource)
if err != nil {
t.Fatalf("marshal storage frontend resource: %v", err)
}
if err := json.Unmarshal(encoded, &storagePayload); err != nil {
t.Fatalf("unmarshal storage frontend resource: %v", err)
}
break
}
if storagePayload.ID != storageID {
t.Fatalf("expected storage resource %q in frontend payload, got %q", storageID, storagePayload.ID)
}
if storagePayload.MetricsTarget.ResourceType != "storage" ||
storagePayload.MetricsTarget.ResourceID != agentID+"/storage:unraid-array" {
t.Fatalf("metrics target = %+v", storagePayload.MetricsTarget)
}
}
func hasFrontendResourceName(resources []models.ResourceFrontend, name string) bool {
for _, resource := range resources {
if resource.Name == name {