fix(reporting): resolve report subjects through the canonical unified view and paginate the metric card grid

Two regressions surfaced by generating a real-mode agent report after a
backend restart:

- Report subject lookups used Monitor.GetUnifiedResources (the raw
  resource store), but the raw store's canonical IDs depend on per-boot
  ingest order for merged-source hosts: after a restart the same host
  resolved to a different agent-<hash> than the one the UI and
  /api/state advertise, so reports lost the resource name, availability,
  and metrics translation entirely. Subject enrichment now reads
  Monitor.UnifiedResourceSnapshot and MetricsTargetForResource resolves
  through GetUnifiedReadStateOrSnapshot first (raw store as fallback) -
  the same re-ingested registry every other read surface uses.
- The performance summary card grid positions cards absolutely and never
  paginated: an agent host reporting 8+ metric families walked off the
  page bottom, fought fpdf's auto page break, and scattered one orphan
  element per page (a 7-day delly report rendered 18 pages, ten of them
  near-blank). The grid now starts a new page before a row that will not
  fit; the same report renders 9 pages with intact cards.

Verified live: real-mode delly report shows name, 288 data points,
availability, charts, and correctly paginated cards. The underlying
canonical-ID instability (raw store vs re-ingested view, and the
resource_changes journal fragmenting across boot eras) is a separate
root issue tracked for its own fix.
This commit is contained in:
rcourtman 2026-06-10 22:02:48 +01:00
parent 47392b5f68
commit 9bcf767b1b
4 changed files with 80 additions and 10 deletions

View file

@ -352,6 +352,12 @@ func (h *ReportingHandlers) getReportingEnrichmentSnapshot(ctx context.Context,
return emptyReportingEnrichmentSnapshot(), false
}
// Resources must come from the canonical unified view (the same
// re-ingested registry the UI and /api/state read), NOT the raw
// resource store: the raw store's canonical IDs depend on per-boot
// ingest order for merged-source hosts, so a UI-supplied resource ID
// can be missing from it entirely.
unifiedResources, _ := monitor.UnifiedResourceSnapshot()
snapshot := reportingEnrichmentSnapshot{
Nodes: monitor.NodesSnapshot(),
VMs: monitor.VMsSnapshot(),
@ -359,7 +365,7 @@ func (h *ReportingHandlers) getReportingEnrichmentSnapshot(ctx context.Context,
ActiveAlerts: monitor.ActiveAlertsSnapshot(),
RecentlyResolved: monitor.RecentlyResolvedSnapshot(),
LegacyBackups: monitor.PVEBackupsSnapshot(),
Resources: monitor.GetUnifiedResources(),
Resources: unifiedResources,
}
snapshot.normalizeCollections()
return snapshot, true

View file

@ -4103,16 +4103,25 @@ func (m *Monitor) GetUnifiedResources() []unifiedresources.Resource {
}
// MetricsTargetForResource resolves the metrics-store target for a canonical
// unified resource ID via the wired resource store. The metrics store is
// keyed by platform-native source IDs, and the target is computed on demand
// by the resource registry rather than persisted on the Resource structs
// GetUnifiedResources returns. Returns nil when no store is wired, the
// store cannot resolve targets, or the resource has none.
// unified resource ID. Resolution goes through the canonical unified view
// (the same re-ingested registry the UI and /api/state read) because
// callers hold IDs from that view: the raw resource store's canonical IDs
// depend on per-boot ingest order for merged-source hosts, so the view and
// the store can disagree about the same machine's ID. The raw store stays
// as a fallback. Returns nil when nothing can resolve a target.
func (m *Monitor) MetricsTargetForResource(resourceID string) *unifiedresources.MetricsTarget {
if m == nil {
return nil
}
if view := m.GetUnifiedReadStateOrSnapshot(); view != nil {
if resolver, ok := view.(MetricsTargetResourceStore); ok {
if target := resolver.MetricsTargetForResource(resourceID); target != nil {
return target
}
}
}
m.mu.RLock()
store := m.resourceStore
m.mu.RUnlock()

View file

@ -884,6 +884,9 @@ func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
startX := 20.0
rowStartY := pdf.GetY()
_, pageHeight := pdf.GetPageSize()
cardBottomLimit := pageHeight - 30
for i, metricType := range metricNames {
stats := data.Summary.ByMetric[metricType]
unit := GetMetricUnit(metricType)
@ -898,6 +901,16 @@ func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
pdf.SetY(rowStartY)
}
// The grid positions cards absolutely, so it must paginate
// itself: a row that would cross the bottom margin previously
// fought fpdf's auto page break and scattered one orphan
// element per page for the rest of the section.
if col == 0 && rowStartY+cardHeight > cardBottomLimit {
pdf.AddPage()
rowStartY = pdf.GetY()
pdf.SetY(rowStartY)
}
cardX := startX + float64(col)*(cardWidth+cardGap)
cardY := rowStartY
@ -946,10 +959,9 @@ func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
writeStat(cardX+46, statsY+6, "Samples", fmt.Sprintf("%d", stats.Count))
}
// Calculate final Y position based on number of rows
numRows := (len(metricNames) + 1) / 2 // Round up
finalY := rowStartY + float64(numRows)*(cardHeight+cardGap)
pdf.SetY(finalY)
// rowStartY tracks the current (possibly paginated) row, so the
// section ends just below the last row of cards.
pdf.SetY(rowStartY + cardHeight + cardGap)
}
// writeChartsSection writes charts for each metric.

View file

@ -467,3 +467,46 @@ func TestGenerateMulti_FlowsResourceBlocksOntoSharedPages(t *testing.T) {
}
}
}
// TestSummarySection_PaginatesCardGridForManyMetrics pins the card grid's
// self-pagination. Agent hosts report 8+ metric families; the absolutely
// positioned grid previously walked off the page bottom and fought fpdf's
// auto page break, scattering one orphan element per page for the rest of
// the section (observed live as ten near-blank pages).
func TestSummarySection_PaginatesCardGridForManyMetrics(t *testing.T) {
byMetric := map[string]MetricStats{}
for _, m := range []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout", "temperature", "iops", "usage"} {
byMetric[m] = MetricStats{Min: 1, Max: 9, Avg: 5, Current: 5, Count: 100}
}
data := &ReportData{
Title: "Many metrics",
ResourceType: "agent",
ResourceID: "agent-1",
Resource: &ResourceInfo{Name: "host-01", Status: "online"},
Start: time.Now().Add(-7 * 24 * time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Summary: MetricSummary{ByMetric: byMetric},
TotalPoints: 1000,
}
gen := NewPDFGenerator()
out, err := gen.Generate(data)
if err != nil {
t.Fatalf("Generate: %v", err)
}
text := extractPDFText(t, out)
pageTotal := regexp.MustCompile(`Page \d+ of (\d+)`).FindStringSubmatch(text)
if pageTotal == nil {
t.Fatalf("no page footer found in:\n%s", text)
}
if pages, _ := strconv.Atoi(pageTotal[1]); pages > 6 {
t.Fatalf("10-metric report rendered %d pages; the card grid must paginate compactly", pages)
}
for _, label := range []string{"Disk Read", "Network Out", "Temperature"} {
if !strings.Contains(text, label) {
t.Fatalf("metric card %q missing after pagination:\n%s", label, text)
}
}
}