fix(reporting): make report PDFs read like client documents, not generated artifacts

A design-partner review of real output called the PDFs out as thin, and
the page renders agreed: fleet reports spent one nearly blank A4 page
per resource, metric cards showed raw store keys with unformatted
values ('diskread 880000.00'), and value cells overlapped the adjacent
column for long readings.

- Fleet per-resource blocks now flow several to a page with separators,
  breaking only when the next block will not fit (a 6-resource report
  drops from 8 pages to 3); each block gains an availability line
- Rate metrics get display names and humane units (Disk Read 859.38
  KiB/s instead of diskread 880000.00); byte units are self-describing
  so the old +unit suffix no longer renders '12.00 GiBbytes'
- Metric card stat columns use bounded cells so long values cannot run
  under the Avg/Samples labels
- Single reports merge Resource Details and Performance Summary onto a
  shared page when they fit instead of stranding a third of a page of
  content on each of two pages

Verified by regenerating branded fleet and single reports and reviewing
every rendered page.
This commit is contained in:
rcourtman 2026-06-10 18:11:42 +01:00
parent 686c2e8716
commit 1e0ea20cd5
4 changed files with 217 additions and 44 deletions

View file

@ -366,12 +366,27 @@ func (g *CSVGenerator) GenerateMulti(data *MultiReportData) ([]byte, error) {
// formatValue formats a metric value with appropriate precision.
func formatValue(value float64, unit string) string {
if unit == "bytes" {
switch unit {
case "bytes":
return formatBytes(value)
case "bytes/s":
return formatBytes(value) + "/s"
}
return fmt.Sprintf("%.2f", value)
}
// formatMetricValue renders a value with its unit for display. Byte-based
// units are self-describing ("1.50 GiB", "880 KiB/s"); symbolic units are
// appended ("22.10%"); unitless values render bare.
func formatMetricValue(value float64, unit string) string {
switch unit {
case "bytes", "bytes/s", "":
return formatValue(value, unit)
default:
return formatValue(value, unit) + unit
}
}
// formatBytes converts bytes to human-readable format.
func formatBytes(bytes float64) string {
const unit = 1024

View file

@ -707,6 +707,18 @@ func GetMetricTypeDisplayName(metricType string) string {
return "Total Space"
case "avail":
return "Available Space"
case "diskread":
return "Disk Read"
case "diskwrite":
return "Disk Write"
case "netin":
return "Network In"
case "netout":
return "Network Out"
case "iops":
return "Disk IOPS"
case "temperature":
return "Temperature"
default:
return metricType
}
@ -719,6 +731,10 @@ func GetMetricUnit(metricType string) string {
return "%"
case "used", "total", "avail":
return "bytes"
case "diskread", "diskwrite", "netin", "netout":
return "bytes/s"
case "temperature":
return "C"
default:
return ""
}

View file

@ -172,10 +172,21 @@ func (g *PDFGenerator) Generate(data *ReportData) ([]byte, error) {
g.addPageHeader(pdf, data, "Executive Summary")
g.writeExecutiveSummary(pdf, data)
// Resource details page (if enrichment data available)
// Resource details (if enrichment data available). The details grid is
// usually a third of a page; giving it a page of its own left the
// report mostly whitespace, so it shares the executive summary page
// whenever there is room.
if data.Resource != nil {
pdf.AddPage()
g.addPageHeader(pdf, data, "Resource Details")
if pdf.GetY() > 160 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Resource Details")
} else {
pdf.Ln(4)
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 9, "Resource Details", "", 1, "L", false, 0, "")
pdf.Ln(2)
}
g.writeResourceDetails(pdf, data)
// Storage section for nodes
@ -203,9 +214,19 @@ func (g *PDFGenerator) Generate(data *ReportData) ([]byte, error) {
g.writeAlertsSection(pdf, data)
}
// Summary page with metrics
pdf.AddPage()
g.addPageHeader(pdf, data, "Performance Summary")
// Summary with metrics. The details grid above is usually short, so
// the performance summary continues on the same page when it fits
// instead of stranding the details on a nearly blank page.
if pdf.GetY() > 110 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Performance Summary")
} else {
pdf.Ln(6)
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 9, "Performance Summary", "", 1, "L", false, 0, "")
pdf.Ln(2)
}
g.writeSummarySection(pdf, data)
// Charts page(s)
@ -900,46 +921,29 @@ func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
pdf.SetXY(cardX+5, cardY+14)
pdf.SetFont("Arial", "B", 20)
pdf.SetTextColor(headerColor[0], headerColor[1], headerColor[2])
pdf.CellFormat(cardWidth-10, 10, formatValue(stats.Current, unit)+unit, "", 1, "L", false, 0, "")
pdf.CellFormat(cardWidth-10, 10, formatMetricValue(stats.Current, unit), "", 1, "L", false, 0, "")
// Stats row
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
statsY := cardY + 28
// Min
pdf.SetXY(cardX+5, statsY)
pdf.CellFormat(25, 5, "Min", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, formatValue(stats.Min, unit)+unit, "", 1, "L", false, 0, "")
// Max
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(cardX+5, statsY+6)
pdf.CellFormat(25, 5, "Max", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, formatValue(stats.Max, unit)+unit, "", 1, "L", false, 0, "")
// Avg
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(cardX+45, statsY)
pdf.CellFormat(15, 5, "Avg", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, formatValue(stats.Avg, unit)+unit, "", 1, "L", false, 0, "")
// Count
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(cardX+45, statsY+6)
pdf.CellFormat(15, 5, "Samples", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, fmt.Sprintf("%d", stats.Count), "", 1, "L", false, 0, "")
// Two bounded label/value column pairs. Value cells must not use
// width 0 (extend to margin): long byte-rate values would run
// under the second column's labels.
writeStat := func(x, y float64, label, value string) {
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(x, y)
pdf.CellFormat(13, 5, label, "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(26, 5, value, "", 0, "L", false, 0, "")
}
writeStat(cardX+5, statsY, "Min", formatMetricValue(stats.Min, unit))
writeStat(cardX+5, statsY+6, "Max", formatMetricValue(stats.Max, unit))
writeStat(cardX+46, statsY, "Avg", formatMetricValue(stats.Avg, unit))
writeStat(cardX+46, statsY+6, "Samples", fmt.Sprintf("%d", stats.Count))
}
// Calculate final Y position based on number of rows
@ -1784,10 +1788,24 @@ func (g *PDFGenerator) GenerateMulti(data *MultiReportData) ([]byte, error) {
g.addMultiPageHeader(pdf, data, "Fleet Summary")
g.writeFleetSummary(pdf, data)
// Pages 3+: Condensed per-resource pages
// Pages 3+: condensed per-resource blocks, flowed several to a page.
// One near-empty A4 page per resource read as padding in client-facing
// reports, so a new page starts only when the next block will not fit.
_, pageHeight := pdf.GetPageSize()
bottomLimit := pageHeight - 30
pageOpen := false
for _, rd := range data.Resources {
pdf.AddPage()
g.addMultiPageHeader(pdf, data, "Resource Detail")
startNewPage := !pageOpen || pdf.GetY()+condensedResourceBlockHeight(rd) > bottomLimit
if startNewPage {
pdf.AddPage()
g.addMultiPageHeader(pdf, data, "Resource Details")
pageOpen = true
} else {
pdf.Ln(3)
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.Line(20, pdf.GetY(), 190, pdf.GetY())
pdf.Ln(5)
}
g.writeCondensedResourcePage(pdf, rd)
}
@ -2329,6 +2347,37 @@ func writeFleetNarrativeSection(pdf *fpdf.Fpdf, fn *FleetNarrative) {
}
// writeCondensedResourcePage writes a condensed single-page view for one resource.
// condensedResourceBlockHeight estimates the vertical space a condensed
// resource block needs so the fleet layout can decide whether it fits on
// the current page. Estimates err slightly high; a block that still
// overflows is carried across pages by fpdf's auto page break.
func condensedResourceBlockHeight(rd *ReportData) float64 {
height := 11.0 + 6.0 + 27.0 + 8.0 // name header + availability line + stats bar + padding
if len(rd.Metrics["cpu"]) >= 2 || len(rd.Metrics["memory"]) >= 2 {
height += 58 // chart title + canvas + legend
}
active := 0
for _, alert := range rd.Alerts {
if alert.ResolvedTime == nil {
active++
}
}
if active > 0 {
shown := active
if shown > 3 {
shown = 4 // three alerts plus the "and N more" line
}
height += 10 + float64(shown)*5
}
if len(rd.Storage) > 0 {
height += 10 + float64(len(rd.Storage))*5
}
if len(rd.Backups) > 0 {
height += 20
}
return height
}
func (g *PDFGenerator) writeCondensedResourcePage(pdf *fpdf.Fpdf, rd *ReportData) {
// Resource header
resourceName := rd.ResourceID
@ -2364,6 +2413,26 @@ func (g *PDFGenerator) writeCondensedResourcePage(pdf *fpdf.Fpdf, rd *ReportData
statusStr += fmt.Sprintf(" | Uptime: %s", formatUptime(rd.Resource.Uptime))
}
pdf.CellFormat(0, 8, statusStr, "", 1, "L", false, 0, "")
// Availability over the window, when the state timeline produced one.
if rd.Availability != nil {
line := "Availability: not observed this period"
lineColor := colorTextMuted
if rd.Availability.Observed() {
line = fmt.Sprintf("Availability: %s", availabilityUptimeLabel(rd.Availability.UptimePercent))
if rd.Availability.DownIncidents > 0 {
outageWord := "outages"
if rd.Availability.DownIncidents == 1 {
outageWord = "outage"
}
line += fmt.Sprintf(" (%d %s, %s down)", rd.Availability.DownIncidents, outageWord, formatOutageDuration(rd.Availability.TotalDowntime))
}
lineColor = colorTextDark
}
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(lineColor[0], lineColor[1], lineColor[2])
pdf.CellFormat(0, 5, line, "", 1, "L", false, 0, "")
}
pdf.Ln(3)
// Stats bar - CPU, Memory, Disk averages and maxes

View file

@ -3,8 +3,10 @@ package reporting
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"testing"
"time"
@ -394,3 +396,74 @@ func TestAvailabilityUptimeLabel_NeverOverstates(t *testing.T) {
t.Fatalf("availabilityUptimeLabel(99.4249) = %q, want 99.42%%", got)
}
}
// TestMetricFormatting_RateMetricsAreHumanReadable pins the display
// vocabulary for the rate metrics that previously rendered as raw keys
// with unformatted values ("diskread 880000.00") in client-facing
// reports.
func TestMetricFormatting_RateMetricsAreHumanReadable(t *testing.T) {
if got := GetMetricTypeDisplayName("diskread"); got != "Disk Read" {
t.Fatalf("GetMetricTypeDisplayName(diskread) = %q", got)
}
if got := GetMetricTypeDisplayName("netout"); got != "Network Out" {
t.Fatalf("GetMetricTypeDisplayName(netout) = %q", got)
}
if got := GetMetricUnit("diskwrite"); got != "bytes/s" {
t.Fatalf("GetMetricUnit(diskwrite) = %q", got)
}
if got := formatMetricValue(880000, "bytes/s"); got != "859.38 KiB/s" {
t.Fatalf("formatMetricValue(880000, bytes/s) = %q", got)
}
if got := formatMetricValue(22.1, "%"); got != "22.10%" {
t.Fatalf("formatMetricValue(22.1, %%) = %q", got)
}
// Byte units are self-describing; the old +unit concatenation
// produced "12.00 GiBbytes".
if got := formatMetricValue(12884901888, "bytes"); got != "12.00 GiB" {
t.Fatalf("formatMetricValue(12GiB, bytes) = %q", got)
}
}
// TestGenerateMulti_FlowsResourceBlocksOntoSharedPages asserts the fleet
// report no longer spends one near-empty A4 page per resource: six sparse
// resources must fit on a handful of pages, not eight.
func TestGenerateMulti_FlowsResourceBlocksOntoSharedPages(t *testing.T) {
now := time.Now()
multi := &MultiReportData{
Title: "Fleet",
Start: now.Add(-24 * time.Hour),
End: now,
GeneratedAt: now,
}
for i := 0; i < 6; i++ {
multi.Resources = append(multi.Resources, &ReportData{
ResourceID: fmt.Sprintf("vm-%d", i),
ResourceType: "vm",
Resource: &ResourceInfo{Name: fmt.Sprintf("guest-%02d", i), Status: "online"},
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 20, Count: 1}}},
TotalPoints: 1,
Availability: &AvailabilityInfo{UptimePercent: 100, ObservedPercent: 100},
})
}
gen := NewPDFGenerator()
out, err := gen.GenerateMulti(multi)
if err != nil {
t.Fatalf("GenerateMulti: %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 > 4 {
t.Fatalf("6 sparse resources rendered %d pages; blocks must flow onto shared pages", pages)
}
for i := 0; i < 6; i++ {
name := fmt.Sprintf("guest-%02d", i)
if !strings.Contains(text, name) {
t.Fatalf("resource %s missing from flowed detail pages:\n%s", name, text)
}
}
}