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

@ -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)
}
}
}