diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index 7818245ba..8f73da02b 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -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 diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 9f07024de..167345627 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -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() diff --git a/pkg/reporting/pdf.go b/pkg/reporting/pdf.go index e1960dc34..ac6c009e6 100644 --- a/pkg/reporting/pdf.go +++ b/pkg/reporting/pdf.go @@ -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. diff --git a/pkg/reporting/pdf_ux_test.go b/pkg/reporting/pdf_ux_test.go index fda4e4844..2e494c932 100644 --- a/pkg/reporting/pdf_ux_test.go +++ b/pkg/reporting/pdf_ux_test.go @@ -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) + } + } +}