Pulse/pkg/reporting/pdf.go
rcourtman 371a04ad43 Fix HEALTHY-on-empty and surface AI discoverability in reports
Found by actually generating two PDFs against the dev server and
holding them in hand — neither was visible by reading code alone.

1. HEALTHY on empty data was misleading. A report against a resource
   with zero data points and no alerts showed a green HEALTHY card
   with "All systems operating normally," contradicting the
   "Data Points: 0" line on the cover. A user reading the report
   would believe their resource was operating cleanly when really
   Pulse had no metrics to evaluate. writeExecutiveSummary now
   detects TotalPoints == 0 and len(Summary.ByMetric) == 0 and
   renders a muted grey "NO DATA / No metrics reported during the
   selected window" card instead.

2. AI discoverability gap. With AI unconfigured (or failing), the
   PDF is functionally identical to what it was before the AI
   narrative work landed — no AI prose, no period comparison, no
   provenance footer. A user has zero signal that AI-narrated
   reports are a separate Pulse Assistant capability. Adds a
   one-line muted tip at the end of the executive summary when
   Narrative.Source == NarrativeSourceHeuristic pointing at
   Settings. Fleet path gets the same nudge scoped to fleet
   synthesis. Mutually exclusive with the AI provenance disclaimer
   so we never show both.

Tests in pdf_ux_test.go inflate FlateDecode'd content streams to
substring-check the actual rendered text, covering empty-data ->
NO DATA, quiet-with-data -> HEALTHY (regression guard), heuristic
narrative -> tip, AI narrative -> disclaimer + no tip, and the
fleet-heuristic tip.
2026-05-11 11:13:34 +01:00

2416 lines
75 KiB
Go

package reporting
import (
"bytes"
"context"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/go-pdf/fpdf"
)
// Color scheme - professional dark blue theme
var (
colorPrimary = [3]int{30, 58, 95} // Dark navy
colorSecondary = [3]int{52, 152, 219} // Bright blue
colorAccent = [3]int{46, 204, 113} // Green
colorWarning = [3]int{241, 196, 15} // Yellow
colorDanger = [3]int{231, 76, 60} // Red
colorTextDark = [3]int{44, 62, 80} // Dark text
colorTextMuted = [3]int{127, 140, 141} // Muted text
colorBackground = [3]int{248, 249, 250} // Light gray bg
colorTableHeader = [3]int{30, 58, 95} // Navy header
colorTableAlt = [3]int{241, 245, 249} // Alternating row
colorGridLine = [3]int{220, 220, 220} // Chart grid
)
// PDFGenerator handles PDF report generation.
type PDFGenerator struct{}
// NewPDFGenerator creates a new PDF generator.
func NewPDFGenerator() *PDFGenerator {
return &PDFGenerator{}
}
// Generate creates a PDF report from the provided data.
func (g *PDFGenerator) Generate(data *ReportData) ([]byte, error) {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(true, 25)
// Cover page
g.writeCoverPage(pdf, data)
// Executive Summary page
pdf.AddPage()
g.addPageHeader(pdf, data, "Executive Summary")
g.writeExecutiveSummary(pdf, data)
// Resource details page (if enrichment data available)
if data.Resource != nil {
pdf.AddPage()
g.addPageHeader(pdf, data, "Resource Details")
g.writeResourceDetails(pdf, data)
// Storage section for nodes
if len(data.Storage) > 0 {
g.writeStorageSection(pdf, data)
}
// Physical disks section for nodes
if len(data.Disks) > 0 {
g.writeDisksSection(pdf, data)
}
// Backups section for VMs/containers
if len(data.Backups) > 0 {
g.writeBackupsSection(pdf, data)
}
}
// Alerts section (if any)
if len(data.Alerts) > 0 {
if pdf.GetY() > 180 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Alerts")
}
g.writeAlertsSection(pdf, data)
}
// Summary page with metrics
pdf.AddPage()
g.addPageHeader(pdf, data, "Performance Summary")
g.writeSummarySection(pdf, data)
// Charts page(s)
g.writeChartsSection(pdf, data)
// Data table page(s)
g.writeDataSection(pdf, data)
// Add page numbers to all pages except cover
g.addPageNumbers(pdf)
// Output to buffer
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output error: %w", err)
}
return buf.Bytes(), nil
}
// writeCoverPage creates a professional cover page.
func (g *PDFGenerator) writeCoverPage(pdf *fpdf.Fpdf, data *ReportData) {
pdf.AddPage()
pageWidth, pageHeight := pdf.GetPageSize()
// Top accent bar
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.Rect(0, 0, pageWidth, 8, "F")
// Pulse branding area
pdf.SetY(50)
pdf.SetFont("Arial", "B", 32)
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.CellFormat(0, 15, "PULSE", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 8, "Infrastructure Monitoring", "", 1, "C", false, 0, "")
// Main title
pdf.SetY(100)
pdf.SetFont("Arial", "B", 28)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 12, "Performance Report", "", 1, "C", false, 0, "")
// Resource info box
pdf.SetY(130)
boxX := 40.0
boxWidth := pageWidth - 80
boxHeight := 50.0
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.RoundedRect(boxX, pdf.GetY(), boxWidth, boxHeight, 3, "1234", "FD")
// Resource details inside box
pdf.SetY(pdf.GetY() + 10)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 7, "RESOURCE", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "B", 16)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 10, data.ResourceID, "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 7, GetResourceTypeDisplayName(data.ResourceType), "", 1, "C", false, 0, "")
// Time period
pdf.SetY(200)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 7, "REPORTING PERIOD", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
periodStr := fmt.Sprintf("%s - %s",
data.Start.Format("January 2, 2006 15:04"),
data.End.Format("January 2, 2006 15:04"))
pdf.CellFormat(0, 8, periodStr, "", 1, "C", false, 0, "")
// Duration
duration := data.End.Sub(data.Start)
durationStr := formatDuration(duration)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 6, fmt.Sprintf("(%s)", durationStr), "", 1, "C", false, 0, "")
// Bottom section
pdf.SetY(pageHeight - 50)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", data.GeneratedAt.Format("January 2, 2006 at 15:04 MST")), "", 1, "C", false, 0, "")
pdf.CellFormat(0, 6, fmt.Sprintf("Data Points: %d", data.TotalPoints), "", 1, "C", false, 0, "")
// Bottom accent bar
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.Rect(0, pageHeight-8, pageWidth, 8, "F")
}
// writeExecutiveSummary writes the executive summary with health status
func (g *PDFGenerator) writeExecutiveSummary(pdf *fpdf.Fpdf, data *ReportData) {
pageWidth, _ := pdf.GetPageSize()
// Determine overall health status
healthStatus := "HEALTHY"
healthColor := colorAccent // Green
healthMessage := "All systems operating normally"
activeAlerts := 0
criticalAlerts := 0
warningAlerts := 0
for _, alert := range data.Alerts {
if alert.ResolvedTime == nil {
activeAlerts++
if alert.Level == "critical" {
criticalAlerts++
} else {
warningAlerts++
}
}
}
if criticalAlerts > 0 {
healthStatus = "CRITICAL"
healthColor = colorDanger
if criticalAlerts == 1 {
healthMessage = "1 critical issue requires immediate attention"
} else {
healthMessage = fmt.Sprintf("%d critical issues require immediate attention", criticalAlerts)
}
} else if warningAlerts > 0 {
healthStatus = "WARNING"
healthColor = colorWarning
if warningAlerts == 1 {
healthMessage = "1 warning detected - review recommended"
} else {
healthMessage = fmt.Sprintf("%d warnings detected - review recommended", warningAlerts)
}
} else if data.TotalPoints == 0 && len(data.Summary.ByMetric) == 0 {
// No metrics arrived for the requested window. Reporting
// HEALTHY would green-light an empty report and mislead the
// operator into thinking the resource is fine when really
// Pulse has no data to evaluate. Fall back to a muted
// "NO DATA" card so the executive summary stays consistent
// with the "Data Points: 0" line on the cover.
healthStatus = "NO DATA"
healthColor = colorTextMuted
healthMessage = "No metrics reported during the selected window"
}
// Health Status Card
cardX := 20.0
cardWidth := pageWidth - 40
cardHeight := 35.0
pdf.SetFillColor(healthColor[0], healthColor[1], healthColor[2])
pdf.RoundedRect(cardX, pdf.GetY(), cardWidth, cardHeight, 3, "1234", "F")
// Health status text
pdf.SetXY(cardX, pdf.GetY()+8)
pdf.SetFont("Arial", "B", 24)
pdf.SetTextColor(255, 255, 255)
pdf.CellFormat(cardWidth, 12, healthStatus, "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 11)
pdf.CellFormat(cardWidth, 8, healthMessage, "", 1, "C", false, 0, "")
pdf.SetY(pdf.GetY() + 15)
// AI-generated executive prose, rendered between the deterministic
// health card and the deterministic Quick Stats table. Only shown when
// the narrator (AI or heuristic) supplied prose; the heuristic narrator
// leaves this empty so existing reports look unchanged when AI is off.
if data.Narrative != nil && strings.TrimSpace(data.Narrative.ExecutiveSummary) != "" {
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.MultiCell(cardWidth, 5, data.Narrative.ExecutiveSummary, "", "L", false)
pdf.Ln(3)
}
// Quick Stats - simple table format (avoids fpdf positioning bugs)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Quick Stats", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Calculate stats
var avgCPU, avgMem, avgDisk float64
if stats, ok := data.Summary.ByMetric["cpu"]; ok {
avgCPU = stats.Avg
}
if stats, ok := data.Summary.ByMetric["memory"]; ok {
avgMem = stats.Avg
}
if stats, ok := data.Summary.ByMetric["disk"]; ok {
avgDisk = stats.Avg
} else if stats, ok := data.Summary.ByMetric["usage"]; ok {
avgDisk = stats.Avg
}
// Simple table header - darker text for better visibility
colWidth := 42.5
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidth, 7, "CPU", "0", 0, "C", true, 0, "")
pdf.CellFormat(colWidth, 7, "Memory", "0", 0, "C", true, 0, "")
pdf.CellFormat(colWidth, 7, "Disk", "0", 0, "C", true, 0, "")
pdf.CellFormat(colWidth, 7, "Alerts", "0", 1, "C", true, 0, "")
// Values row - large numbers
pdf.SetFont("Arial", "B", 16)
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgCPU), "0", 0, "C", false, 0, "")
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgMem), "0", 0, "C", false, 0, "")
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgDisk), "0", 0, "C", false, 0, "")
pdf.SetTextColor(getAlertCountColor(activeAlerts)[0], getAlertCountColor(activeAlerts)[1], getAlertCountColor(activeAlerts)[2])
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%d", activeAlerts), "0", 1, "C", false, 0, "")
// Labels row with trend indicators
pdf.SetFont("Arial", "", 7)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
// Calculate trends (compare first half avg to second half avg)
cpuTrend := g.calculateTrend(data, "cpu")
memTrend := g.calculateTrend(data, "memory")
diskTrend := g.calculateTrend(data, "disk")
if diskTrend == "" {
diskTrend = g.calculateTrend(data, "usage")
}
pdf.CellFormat(colWidth, 5, "avg "+cpuTrend, "0", 0, "C", false, 0, "")
pdf.CellFormat(colWidth, 5, "avg "+memTrend, "0", 0, "C", false, 0, "")
pdf.CellFormat(colWidth, 5, "avg "+diskTrend, "0", 0, "C", false, 0, "")
pdf.CellFormat(colWidth, 5, "active now", "0", 1, "C", false, 0, "")
pdf.Ln(5)
// Key Observations section
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Key Observations", "", 1, "L", false, 0, "")
pdf.Ln(2)
observations := g.generateObservations(data)
pdf.SetFont("Arial", "", 10)
for _, obs := range observations {
// Draw colored bullet circle
bulletX := pdf.GetX() + 3
bulletY := pdf.GetY() + 3
pdf.SetFillColor(obs.color[0], obs.color[1], obs.color[2])
pdf.Circle(bulletX, bulletY, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 6, obs.text, "", 1, "L", false, 0, "")
pdf.Ln(1)
}
// Active Alerts summary (if any)
if activeAlerts > 0 {
pdf.Ln(5)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Active Alerts", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 9)
alertCount := 0
for _, alert := range data.Alerts {
if alert.ResolvedTime == nil && alertCount < 5 {
if alert.Level == "critical" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
pdf.CellFormat(8, 5, "!", "", 0, "C", false, 0, "")
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
pdf.CellFormat(8, 5, "!", "", 0, "C", false, 0, "")
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
msg := alert.Message
if len(msg) > 70 {
msg = msg[:67] + "..."
}
pdf.CellFormat(0, 5, msg, "", 1, "L", false, 0, "")
alertCount++
}
}
if activeAlerts > 5 {
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 5, fmt.Sprintf("... and %d more alerts", activeAlerts-5), "", 1, "L", false, 0, "")
}
}
// Recommendations section
recommendations := g.generateRecommendations(data, criticalAlerts, warningAlerts)
if len(recommendations) > 0 {
pdf.Ln(5)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Recommended Actions", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 9)
for i, rec := range recommendations {
if i >= 4 {
break // Limit to 4 recommendations
}
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
pdf.CellFormat(6, 5, fmt.Sprintf("%d.", i+1), "", 0, "L", false, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, rec, "", 1, "L", false, 0, "")
pdf.Ln(1)
}
}
// Period-over-period comparison. The AI narrator populates this when
// the engine supplied prior-window stats; the heuristic narrator leaves
// it empty.
if data.Narrative != nil {
comparison := strings.TrimSpace(data.Narrative.PeriodComparison)
if comparison != "" {
pdf.Ln(5)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Period-over-period changes", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 9)
pdf.MultiCell(pageWidth-40, 5, comparison, "", "L", false)
}
disclaimer := strings.TrimSpace(data.Narrative.Disclaimer)
if disclaimer != "" {
pdf.Ln(4)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.MultiCell(pageWidth-40, 4, disclaimer, "", "L", false)
} else if data.Narrative.Source == NarrativeSourceHeuristic {
// Discoverability nudge: when the report fell back to the
// deterministic heuristic narrator, surface a one-line tip
// so users learn the AI upgrade exists. Without this, a
// user whose AI is unconfigured (or whose provider failed)
// has zero indication that AI-narrated reports are a
// separate capability behind Pulse Assistant.
pdf.Ln(4)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.MultiCell(pageWidth-40, 4,
"Tip: Configure Pulse Assistant in Settings to add AI-narrated executive summaries, fleet outlier detection, and period-over-period comparison to future reports.",
"", "L", false)
}
}
pdf.Ln(10)
}
// observation represents a key observation for the executive summary
type observation struct {
icon string
text string
color [3]int
}
// generateObservations returns the observation bullets for the executive
// summary. When data.Narrative is set (AI-generated or pre-computed
// heuristic), its bullets are used directly. Otherwise the heuristic
// narrator is invoked synchronously.
func (g *PDFGenerator) generateObservations(data *ReportData) []observation {
bullets := narrativeBulletsForRender(data)
if len(bullets) == 0 {
return []observation{{
icon: "-",
text: "Insufficient data for detailed analysis",
color: colorTextMuted,
}}
}
out := make([]observation, 0, len(bullets))
for _, b := range bullets {
out = append(out, observation{
icon: "-",
text: b.Text,
color: bulletColor(b.Severity),
})
}
return out
}
func narrativeBulletsForRender(data *ReportData) []NarrativeBullet {
if data == nil {
return nil
}
if data.Narrative != nil && len(data.Narrative.Observations) > 0 {
return data.Narrative.Observations
}
in := narrativeInputFromReport(data, nil, nil)
out, _ := HeuristicNarrator{}.Narrate(context.Background(), in)
return out.Observations
}
func bulletColor(severity string) [3]int {
switch severity {
case NarrativeSeverityCritical:
return colorDanger
case NarrativeSeverityWarning:
return colorWarning
case NarrativeSeverityInfo:
return colorSecondary
case NarrativeSeverityOK:
return colorAccent
default:
return colorTextMuted
}
}
// calculateTrend compares first half to second half of data points
func (g *PDFGenerator) calculateTrend(data *ReportData, metricType string) string {
points, ok := data.Metrics[metricType]
if !ok || len(points) < 10 {
return ""
}
// Calculate average of first half and second half
mid := len(points) / 2
var firstSum, secondSum float64
for i := 0; i < mid; i++ {
firstSum += points[i].Value
}
for i := mid; i < len(points); i++ {
secondSum += points[i].Value
}
firstAvg := firstSum / float64(mid)
secondAvg := secondSum / float64(len(points)-mid)
// Calculate percentage change
if firstAvg == 0 {
return ""
}
change := ((secondAvg - firstAvg) / firstAvg) * 100
// Only show trend if significant (>5% change)
if change > 5 {
return "(trending up)"
} else if change < -5 {
return "(trending down)"
}
return "(stable)"
}
// generateRecommendations returns the recommendations list for the executive
// summary. When data.Narrative is set, its recommendations are returned
// directly. Otherwise the heuristic narrator is invoked synchronously. The
// criticalAlerts and warningAlerts arguments are retained for callers but
// are derived from data.Alerts inside the heuristic narrator.
func (g *PDFGenerator) generateRecommendations(data *ReportData, criticalAlerts, warningAlerts int) []string {
_, _ = criticalAlerts, warningAlerts
if data != nil && data.Narrative != nil && len(data.Narrative.Recommendations) > 0 {
return data.Narrative.Recommendations
}
in := narrativeInputFromReport(data, nil, nil)
out, _ := HeuristicNarrator{}.Narrate(context.Background(), in)
return out.Recommendations
}
// getStatColor returns color based on percentage value
func getStatColor(val float64) [3]int {
if val >= 90 {
return colorDanger
} else if val >= 75 {
return colorWarning
}
return colorAccent
}
// getAlertCountColor returns color based on alert count
func getAlertCountColor(count int) [3]int {
if count > 0 {
return colorDanger
}
return colorAccent
}
// addPageHeader adds a consistent header to content pages.
func (g *PDFGenerator) addPageHeader(pdf *fpdf.Fpdf, data *ReportData, section string) {
pageWidth, _ := pdf.GetPageSize()
// Top line
pdf.SetDrawColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.SetLineWidth(0.5)
pdf.Line(20, 15, pageWidth-20, 15)
// Header text
pdf.SetY(18)
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.CellFormat(0, 5, "PULSE PERFORMANCE REPORT", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 5, data.ResourceID, "", 1, "R", false, 0, "")
// Section title
pdf.SetY(30)
pdf.SetFont("Arial", "B", 18)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 10, section, "", 1, "L", false, 0, "")
pdf.Ln(5)
}
// writeSummarySection writes the metrics summary with stats cards.
func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
// Time period subtitle
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
periodStr := fmt.Sprintf("Statistics for %s to %s (%s)",
data.Start.Format("Jan 2, 2006 15:04"),
data.End.Format("Jan 2, 2006 15:04"),
formatDuration(data.End.Sub(data.Start)))
pdf.CellFormat(0, 6, periodStr, "", 1, "L", false, 0, "")
pdf.Ln(5)
if len(data.Summary.ByMetric) == 0 {
pdf.SetFont("Arial", "I", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 10, "No metrics data available for this period.", "", 1, "L", false, 0, "")
return
}
// Get sorted metric names
metricNames := make([]string, 0, len(data.Summary.ByMetric))
for name := range data.Summary.ByMetric {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
// Stats cards - 2 per row
cardWidth := 82.0
cardHeight := 45.0
cardGap := 6.0
startX := 20.0
rowStartY := pdf.GetY()
for i, metricType := range metricNames {
stats := data.Summary.ByMetric[metricType]
unit := GetMetricUnit(metricType)
col := i % 2
if col == 0 && i > 0 {
// Move to next row
rowStartY = rowStartY + cardHeight + cardGap
pdf.SetY(rowStartY)
} else if col == 1 {
// Second column - return to row start Y
pdf.SetY(rowStartY)
}
cardX := startX + float64(col)*(cardWidth+cardGap)
cardY := rowStartY
// Card background
pdf.SetFillColor(255, 255, 255)
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.RoundedRect(cardX, cardY, cardWidth, cardHeight, 2, "1234", "FD")
// Card header with color bar
headerColor := getMetricColor(metricType)
pdf.SetFillColor(headerColor[0], headerColor[1], headerColor[2])
pdf.Rect(cardX, cardY, cardWidth, 3, "F")
// Metric name
pdf.SetXY(cardX+5, cardY+6)
pdf.SetFont("Arial", "B", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(cardWidth-10, 6, GetMetricTypeDisplayName(metricType), "", 1, "L", false, 0, "")
// Current value (large)
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, "")
// 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, "")
}
// 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)
}
// writeChartsSection writes charts for each metric.
func (g *PDFGenerator) writeChartsSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Metrics) == 0 {
return
}
// Get sorted metric names
metricNames := make([]string, 0, len(data.Metrics))
for name := range data.Metrics {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
chartWidth := 170.0
chartHeight := 55.0
// Count valid charts
validCharts := 0
for _, metricType := range metricNames {
if len(data.Metrics[metricType]) >= 2 {
validCharts++
}
}
if validCharts == 0 {
return
}
// Always start charts on a new page with proper header
pdf.AddPage()
g.addPageHeader(pdf, data, "Performance Charts")
for _, metricType := range metricNames {
points := data.Metrics[metricType]
if len(points) < 2 {
continue
}
// Check if we need a new page (need space for chart title + chart + labels)
if pdf.GetY() > 195 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Performance Charts")
}
// Chart title
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
unit := GetMetricUnit(metricType)
titleStr := GetMetricTypeDisplayName(metricType)
if unit != "" {
titleStr = fmt.Sprintf("%s (%s)", titleStr, unit)
}
pdf.CellFormat(0, 7, titleStr, "", 1, "L", false, 0, "")
chartX := 20.0
chartY := pdf.GetY()
g.drawChart(pdf, points, chartX, chartY, chartWidth, chartHeight, metricType)
pdf.SetY(chartY + chartHeight + 12)
}
}
// drawChart draws a single chart with grid, area fill, and line.
func (g *PDFGenerator) drawChart(pdf *fpdf.Fpdf, points []MetricDataPoint, x, y, width, height float64, metricType string) {
// Chart background
pdf.SetFillColor(255, 255, 255)
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.SetLineWidth(0.3)
pdf.Rect(x, y, width, height, "FD")
// Find min/max for scaling
minVal, maxVal := points[0].Value, points[0].Value
for _, p := range points {
if p.Value < minVal {
minVal = p.Value
}
if p.Value > maxVal {
maxVal = p.Value
}
}
// Add padding to range
valRange := maxVal - minVal
if valRange < 1 {
valRange = 10
}
minVal = math.Max(0, minVal-valRange*0.1)
maxVal = maxVal + valRange*0.1
// Draw horizontal grid lines and Y-axis labels
pdf.SetFont("Arial", "", 7)
numGridLines := 5
for i := 0; i <= numGridLines; i++ {
gridY := y + height - (float64(i)/float64(numGridLines))*height
val := minVal + (float64(i)/float64(numGridLines))*(maxVal-minVal)
// Grid line
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.SetLineWidth(0.1)
pdf.Line(x, gridY, x+width, gridY)
// Y-axis label
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(x-15, gridY-2)
pdf.CellFormat(12, 5, fmt.Sprintf("%.0f", val), "", 0, "R", false, 0, "")
}
// Time calculations
startTime := points[0].Timestamp.Unix()
endTime := points[len(points)-1].Timestamp.Unix()
timeRange := float64(endTime - startTime)
if timeRange == 0 {
timeRange = 1
}
// Build polygon points for area fill
chartColor := getMetricColor(metricType)
// Draw area fill
pdf.SetFillColor(chartColor[0], chartColor[1], chartColor[2])
pdf.SetAlpha(0.15, "Normal")
polyStr := ""
for i, p := range points {
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
if i == 0 {
polyStr = fmt.Sprintf("%.2f %.2f m ", xPos, y+height-2)
}
polyStr += fmt.Sprintf("%.2f %.2f l ", xPos, yPos)
}
// Close polygon
lastX := x + 2 + (float64(points[len(points)-1].Timestamp.Unix()-startTime)/timeRange)*(width-4)
polyStr += fmt.Sprintf("%.2f %.2f l h f", lastX, y+height-2)
// Use raw PDF drawing for polygon
// Actually, fpdf doesn't support arbitrary polygons easily, so we'll use a different approach
// Draw as many small rectangles to approximate the fill
pdf.SetAlpha(0.2, "Normal")
for i := 1; i < len(points); i++ {
p1 := points[i-1]
p2 := points[i]
x1 := x + 2 + (float64(p1.Timestamp.Unix()-startTime)/timeRange)*(width-4)
x2 := x + 2 + (float64(p2.Timestamp.Unix()-startTime)/timeRange)*(width-4)
y1 := y + height - 2 - ((p1.Value-minVal)/(maxVal-minVal))*(height-4)
y2 := y + height - 2 - ((p2.Value-minVal)/(maxVal-minVal))*(height-4)
y1 = math.Max(y+2, math.Min(y+height-2, y1))
y2 = math.Max(y+2, math.Min(y+height-2, y2))
// Draw trapezoid approximation using polygon
pdf.Polygon([]fpdf.PointType{
{X: x1, Y: y1},
{X: x2, Y: y2},
{X: x2, Y: y + height - 2},
{X: x1, Y: y + height - 2},
}, "F")
}
pdf.SetAlpha(1, "Normal")
// Draw the line
pdf.SetDrawColor(chartColor[0], chartColor[1], chartColor[2])
pdf.SetLineWidth(0.8)
prevX, prevY := 0.0, 0.0
for i, p := range points {
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
if i > 0 {
pdf.Line(prevX, prevY, xPos, yPos)
}
prevX, prevY = xPos, yPos
}
// X-axis labels
pdf.SetFont("Arial", "", 7)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.SetXY(x, y+height+1)
pdf.CellFormat(40, 4, points[0].Timestamp.Format("Jan 2 15:04"), "", 0, "L", false, 0, "")
pdf.SetXY(x+width-40, y+height+1)
pdf.CellFormat(40, 4, points[len(points)-1].Timestamp.Format("Jan 2 15:04"), "", 0, "R", false, 0, "")
}
// writeDataSection writes the data table.
func (g *PDFGenerator) writeDataSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Metrics) == 0 {
return
}
// Always start data section on a new page with proper header
pdf.AddPage()
g.addPageHeader(pdf, data, "Data Sample")
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 6, "Showing up to 50 data points. Export as CSV for the complete dataset.", "", 1, "L", false, 0, "")
pdf.Ln(3)
// Get sorted metric names
metricNames := make([]string, 0, len(data.Metrics))
for name := range data.Metrics {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
// Calculate column widths - ensure enough space for metric headers
timestampWidth := 35.0
availableWidth := 170.0 - timestampWidth
metricWidth := availableWidth / float64(len(metricNames))
if metricWidth < 30 {
metricWidth = 30
}
// Table header
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 7)
pdf.CellFormat(timestampWidth, 7, "Timestamp", "1", 0, "C", true, 0, "")
for _, name := range metricNames {
displayName := GetMetricTypeDisplayName(name)
unit := GetMetricUnit(name)
if unit != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, unit)
}
if len(displayName) > 18 {
displayName = displayName[:18]
}
pdf.CellFormat(metricWidth, 7, displayName, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Collect timestamps
timestampSet := make(map[int64]bool)
metricsByTime := make(map[string]map[int64]float64)
for metricName, points := range data.Metrics {
metricsByTime[metricName] = make(map[int64]float64)
for _, p := range points {
ts := p.Timestamp.Unix()
timestampSet[ts] = true
metricsByTime[metricName][ts] = p.Value
}
}
timestamps := make([]int64, 0, len(timestampSet))
for ts := range timestampSet {
timestamps = append(timestamps, ts)
}
sort.Slice(timestamps, func(i, j int) bool { return timestamps[i] < timestamps[j] })
// Limit rows
if len(timestamps) > 50 {
timestamps = timestamps[:50]
}
// Table rows
pdf.SetFont("Arial", "", 7)
fill := false
for rowIdx, ts := range timestamps {
// Check page break
if pdf.GetY() > 260 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Data Sample (continued)")
pdf.Ln(5)
// Re-draw header
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 7)
pdf.CellFormat(timestampWidth, 7, "Timestamp", "1", 0, "C", true, 0, "")
for _, name := range metricNames {
displayName := GetMetricTypeDisplayName(name)
unit := GetMetricUnit(name)
if unit != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, unit)
}
if len(displayName) > 18 {
displayName = displayName[:18]
}
pdf.CellFormat(metricWidth, 7, displayName, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Arial", "", 7)
fill = false
}
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
t := time.Unix(ts, 0)
pdf.CellFormat(timestampWidth, 6, t.Format("Jan 02 15:04:05"), "1", 0, "L", fill, 0, "")
for _, metricName := range metricNames {
if val, ok := metricsByTime[metricName][ts]; ok {
pdf.CellFormat(metricWidth, 6, fmt.Sprintf("%.2f", val), "1", 0, "C", fill, 0, "")
} else {
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(metricWidth, 6, "-", "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
}
}
pdf.Ln(-1)
fill = !fill
_ = rowIdx
}
}
// writeResourceDetails writes resource information section
func (g *PDFGenerator) writeResourceDetails(pdf *fpdf.Fpdf, data *ReportData) {
if data.Resource == nil {
return
}
res := data.Resource
// Info grid - 2 columns for short fields, full width for long fields
pdf.SetFont("Arial", "", 10)
leftCol := 20.0
rightCol := 105.0
labelWidth := 30.0
valueWidth := 50.0
// Helper to write a label-value pair in a column
writeField := func(x float64, label, value string) {
pdf.SetXY(x, pdf.GetY())
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
// Truncate long values to fit in column
if len(value) > 35 {
value = value[:32] + "..."
}
pdf.CellFormat(valueWidth, 6, value, "", 0, "L", false, 0, "")
}
// Helper to write a full-width label-value pair
writeFullWidth := func(label, value string) {
pdf.SetX(leftCol)
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
// Allow longer values for full-width fields
if len(value) > 80 {
value = value[:77] + "..."
}
pdf.CellFormat(0, 6, value, "", 1, "L", false, 0, "")
}
startY := pdf.GetY()
// Left column - basic info
writeField(leftCol, "Name:", res.Name)
pdf.SetY(pdf.GetY() + 7)
writeField(leftCol, "Status:", res.Status)
pdf.SetY(pdf.GetY() + 7)
if res.Node != "" {
writeField(leftCol, "Node:", res.Node)
pdf.SetY(pdf.GetY() + 7)
}
if res.Host != "" {
writeField(leftCol, "Host:", res.Host)
pdf.SetY(pdf.GetY() + 7)
}
if res.Uptime > 0 {
writeField(leftCol, "Uptime:", formatUptime(res.Uptime))
pdf.SetY(pdf.GetY() + 7)
}
leftEndY := pdf.GetY()
// Right column - hardware info
pdf.SetY(startY)
if res.CPUCores > 0 {
coreStr := fmt.Sprintf("%d cores", res.CPUCores)
if res.CPUSockets > 0 {
coreStr = fmt.Sprintf("%d cores (%d sockets)", res.CPUCores, res.CPUSockets)
}
writeField(rightCol, "Cores:", coreStr)
pdf.SetY(pdf.GetY() + 7)
}
if res.MemoryTotal > 0 {
writeField(rightCol, "Memory:", formatBytes(float64(res.MemoryTotal)))
pdf.SetY(pdf.GetY() + 7)
}
if res.DiskTotal > 0 {
writeField(rightCol, "Disk:", formatBytes(float64(res.DiskTotal)))
pdf.SetY(pdf.GetY() + 7)
}
if res.Temperature != nil {
writeField(rightCol, "CPU Temp:", fmt.Sprintf("%.0fC", *res.Temperature))
pdf.SetY(pdf.GetY() + 7)
}
if len(res.LoadAverage) >= 3 {
writeField(rightCol, "Load:", fmt.Sprintf("%.2f, %.2f, %.2f", res.LoadAverage[0], res.LoadAverage[1], res.LoadAverage[2]))
pdf.SetY(pdf.GetY() + 7)
}
rightEndY := pdf.GetY()
if leftEndY > rightEndY {
pdf.SetY(leftEndY)
}
pdf.SetY(pdf.GetY() + 3)
// Full-width fields for long values
if res.CPUModel != "" {
writeFullWidth("CPU:", res.CPUModel)
}
if res.KernelVersion != "" {
writeFullWidth("Kernel:", res.KernelVersion)
}
if res.PVEVersion != "" {
writeFullWidth("PVE:", res.PVEVersion)
}
if res.OSName != "" {
osStr := res.OSName
if res.OSVersion != "" {
osStr = fmt.Sprintf("%s %s", res.OSName, res.OSVersion)
}
writeFullWidth("OS:", osStr)
}
if len(res.IPAddresses) > 0 {
writeFullWidth("IP:", res.IPAddresses[0])
}
// Tags
if len(res.Tags) > 0 {
pdf.SetY(pdf.GetY() + 3)
pdf.SetX(leftCol)
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(labelWidth, 6, "Tags:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
tagStr := ""
for i, tag := range res.Tags {
if i > 0 {
tagStr += ", "
}
tagStr += tag
}
pdf.CellFormat(0, 6, tagStr, "", 1, "L", false, 0, "")
}
pdf.Ln(8)
}
// writeAlertsSection writes the alerts table
func (g *PDFGenerator) writeAlertsSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Alerts) == 0 {
return
}
pdf.SetFont("Arial", "B", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Alerts During Period", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
colWidths := []float64{25, 20, 65, 30, 30}
headers := []string{"Type", "Level", "Message", "Started", "Resolved"}
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
for i, header := range headers {
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 8)
fill := false
for _, alert := range data.Alerts {
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
// Type
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[0], 6, alert.Type, "1", 0, "L", fill, 0, "")
// Level with color
if alert.Level == "critical" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
pdf.CellFormat(colWidths[1], 6, alert.Level, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
// Message (truncate if too long)
msg := alert.Message
if len(msg) > 45 {
msg = msg[:42] + "..."
}
pdf.CellFormat(colWidths[2], 6, msg, "1", 0, "L", fill, 0, "")
// Started
pdf.CellFormat(colWidths[3], 6, alert.StartTime.Format("Jan 02 15:04"), "1", 0, "C", fill, 0, "")
// Resolved
if alert.ResolvedTime != nil {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
pdf.CellFormat(colWidths[4], 6, alert.ResolvedTime.Format("Jan 02 15:04"), "1", 0, "C", fill, 0, "")
} else {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
pdf.CellFormat(colWidths[4], 6, "Active", "1", 0, "C", fill, 0, "")
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(10)
}
// writeStorageSection writes storage pools table
func (g *PDFGenerator) writeStorageSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Storage) == 0 {
return
}
// Check if we need a new page
if pdf.GetY() > 200 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Storage Pools")
}
pdf.SetFont("Arial", "B", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Storage Pools", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
colWidths := []float64{35, 25, 20, 30, 30, 30}
headers := []string{"Name", "Type", "Status", "Used", "Total", "Usage"}
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
for i, header := range headers {
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 8)
fill := false
for _, storage := range data.Storage {
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[0], 6, storage.Name, "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[1], 6, storage.Type, "1", 0, "C", fill, 0, "")
// Status with color
if storage.Status == "active" || storage.Status == "available" {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
pdf.CellFormat(colWidths[2], 6, storage.Status, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[3], 6, formatBytes(float64(storage.Used)), "1", 0, "R", fill, 0, "")
pdf.CellFormat(colWidths[4], 6, formatBytes(float64(storage.Total)), "1", 0, "R", fill, 0, "")
// Usage with color coding
usageStr := fmt.Sprintf("%.1f%%", storage.UsagePerc)
if storage.UsagePerc >= 90 {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else if storage.UsagePerc >= 80 {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
} else {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
}
pdf.CellFormat(colWidths[5], 6, usageStr, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(10)
}
// writeDisksSection writes physical disks table
func (g *PDFGenerator) writeDisksSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Disks) == 0 {
return
}
// Check if we need a new page
if pdf.GetY() > 200 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Physical Disks")
}
pdf.SetFont("Arial", "B", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Physical Disks", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
colWidths := []float64{25, 50, 25, 25, 20, 25}
headers := []string{"Device", "Model", "Size", "Health", "Temp", "Life"}
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
for i, header := range headers {
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 8)
fill := false
for _, disk := range data.Disks {
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[0], 6, disk.Device, "1", 0, "L", fill, 0, "")
// Model (truncate if too long)
model := disk.Model
if len(model) > 30 {
model = model[:27] + "..."
}
pdf.CellFormat(colWidths[1], 6, model, "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[2], 6, formatBytes(float64(disk.Size)), "1", 0, "R", fill, 0, "")
// Health with color
if disk.Health == "PASSED" {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
} else if disk.Health == "FAILED" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
pdf.CellFormat(colWidths[3], 6, disk.Health, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
// Temperature
tempStr := "-"
if disk.Temperature > 0 {
tempStr = fmt.Sprintf("%dC", disk.Temperature)
if disk.Temperature >= 60 {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else if disk.Temperature >= 50 {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
}
pdf.CellFormat(colWidths[4], 6, tempStr, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
// SSD Life remaining (100% = healthy, 0% = end of life)
lifeStr := "-"
if disk.WearLevel > 0 && disk.WearLevel <= 100 {
lifeStr = fmt.Sprintf("%d%%", disk.WearLevel)
if disk.WearLevel <= 10 {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else if disk.WearLevel <= 30 {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
} else {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
}
}
pdf.CellFormat(colWidths[5], 6, lifeStr, "1", 0, "C", fill, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(10)
}
// writeBackupsSection writes backups table
func (g *PDFGenerator) writeBackupsSection(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Backups) == 0 {
return
}
// Check if we need a new page
if pdf.GetY() > 200 {
pdf.AddPage()
g.addPageHeader(pdf, data, "Backups")
}
pdf.SetFont("Arial", "B", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Backups", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
colWidths := []float64{25, 35, 45, 35, 30}
headers := []string{"Type", "Storage", "Date", "Size", "Protected"}
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
for i, header := range headers {
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 8)
fill := false
for _, backup := range data.Backups {
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[0], 6, backup.Type, "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[1], 6, backup.Storage, "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[2], 6, backup.Timestamp.Format("2006-01-02 15:04"), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[3], 6, formatBytes(float64(backup.Size)), "1", 0, "R", fill, 0, "")
// Protected
if backup.Protected {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
pdf.CellFormat(colWidths[4], 6, "Yes", "1", 0, "C", fill, 0, "")
} else {
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(colWidths[4], 6, "No", "1", 0, "C", fill, 0, "")
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(10)
}
// formatUptime converts seconds to human-readable uptime
func formatUptime(seconds int64) string {
days := seconds / 86400
hours := (seconds % 86400) / 3600
mins := (seconds % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// addPageNumbers adds page numbers to all pages except the first (cover).
func (g *PDFGenerator) addPageNumbers(pdf *fpdf.Fpdf) {
// Disable auto page break while adding footers to prevent creating new pages
pdf.SetAutoPageBreak(false, 0)
totalPages := pdf.PageCount()
// Iterate through pages 2 to totalPages (skip cover page)
for i := 2; i <= totalPages; i++ {
pdf.SetPage(i)
pageWidth, pageHeight := pdf.GetPageSize()
pdf.SetY(pageHeight - 15)
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pageNum := i - 1
totalContent := totalPages - 1
pdf.CellFormat(0, 5, fmt.Sprintf("Page %d of %d", pageNum, totalContent), "", 0, "C", false, 0, "")
// Bottom line
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.SetLineWidth(0.3)
pdf.Line(20, pageHeight-20, pageWidth-20, pageHeight-20)
}
}
// GenerateMulti creates a multi-resource PDF report from the provided data.
func (g *PDFGenerator) GenerateMulti(data *MultiReportData) ([]byte, error) {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(true, 25)
// Page 1: Cover page
g.writeMultiCoverPage(pdf, data)
// Page 2: Fleet summary
pdf.AddPage()
g.addMultiPageHeader(pdf, data, "Fleet Summary")
g.writeFleetSummary(pdf, data)
// Pages 3+: Condensed per-resource pages
for _, rd := range data.Resources {
pdf.AddPage()
g.addMultiPageHeader(pdf, data, "Resource Detail")
g.writeCondensedResourcePage(pdf, rd)
}
// Add page numbers to all pages except cover
g.addMultiPageNumbers(pdf)
// Output to buffer
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output error: %w", err)
}
return buf.Bytes(), nil
}
// writeMultiCoverPage creates a cover page for multi-resource reports.
func (g *PDFGenerator) writeMultiCoverPage(pdf *fpdf.Fpdf, data *MultiReportData) {
pdf.AddPage()
pageWidth, pageHeight := pdf.GetPageSize()
// Top accent bar
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.Rect(0, 0, pageWidth, 8, "F")
// Pulse branding area
pdf.SetY(50)
pdf.SetFont("Arial", "B", 32)
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.CellFormat(0, 15, "PULSE", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 8, "Infrastructure Monitoring", "", 1, "C", false, 0, "")
// Main title
pdf.SetY(100)
pdf.SetFont("Arial", "B", 28)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 12, data.Title, "", 1, "C", false, 0, "")
// Subtitle with counts
pdf.SetY(120)
pdf.SetFont("Arial", "", 14)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
// Calculate duration
duration := data.End.Sub(data.Start)
durationStr := formatDuration(duration)
subtitle := fmt.Sprintf("%d Resources | %s", len(data.Resources), durationStr)
pdf.CellFormat(0, 8, subtitle, "", 1, "C", false, 0, "")
// Scope box
pdf.SetY(140)
boxX := 40.0
boxWidth := pageWidth - 80
boxHeight := 40.0
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.RoundedRect(boxX, pdf.GetY(), boxWidth, boxHeight, 3, "1234", "FD")
// Count by type
nodeCount, vmCount, ctCount := 0, 0, 0
for _, rd := range data.Resources {
switch CanonicalResourceType(rd.ResourceType) {
case "node":
nodeCount++
case "vm":
vmCount++
case "system-container", "oci-container", "app-container":
ctCount++
}
}
pdf.SetY(pdf.GetY() + 10)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 7, "SCOPE", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
var scopeParts []string
if nodeCount > 0 {
word := "Nodes"
if nodeCount == 1 {
word = "Node"
}
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", nodeCount, word))
}
if vmCount > 0 {
word := "VMs"
if vmCount == 1 {
word = "VM"
}
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", vmCount, word))
}
if ctCount > 0 {
word := "Containers"
if ctCount == 1 {
word = "Container"
}
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", ctCount, word))
}
scopeStr := ""
for i, part := range scopeParts {
if i > 0 {
scopeStr += ", "
}
scopeStr += part
}
pdf.CellFormat(0, 8, scopeStr, "", 1, "C", false, 0, "")
// Time period
pdf.SetY(200)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 7, "REPORTING PERIOD", "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
periodStr := fmt.Sprintf("%s - %s",
data.Start.Format("January 2, 2006 15:04"),
data.End.Format("January 2, 2006 15:04"))
pdf.CellFormat(0, 8, periodStr, "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 6, fmt.Sprintf("(%s)", durationStr), "", 1, "C", false, 0, "")
// Bottom section
pdf.SetY(pageHeight - 50)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", data.GeneratedAt.Format("January 2, 2006 at 15:04 MST")), "", 1, "C", false, 0, "")
pdf.CellFormat(0, 6, fmt.Sprintf("Total Data Points: %d", data.TotalPoints), "", 1, "C", false, 0, "")
// Bottom accent bar
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.Rect(0, pageHeight-8, pageWidth, 8, "F")
}
// addMultiPageHeader adds a consistent header to multi-report content pages.
func (g *PDFGenerator) addMultiPageHeader(pdf *fpdf.Fpdf, data *MultiReportData, section string) {
pageWidth, _ := pdf.GetPageSize()
// Top line
pdf.SetDrawColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.SetLineWidth(0.5)
pdf.Line(20, 15, pageWidth-20, 15)
// Header text
pdf.SetY(18)
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.CellFormat(0, 5, "PULSE FLEET REPORT", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 5, fmt.Sprintf("%d Resources", len(data.Resources)), "", 1, "R", false, 0, "")
// Section title
pdf.SetY(30)
pdf.SetFont("Arial", "B", 18)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 10, section, "", 1, "L", false, 0, "")
pdf.Ln(5)
}
// writeFleetSummary writes the fleet summary table and observations.
func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) {
pageWidth, _ := pdf.GetPageSize()
// Determine aggregate health
healthStatus := "HEALTHY"
healthColor := colorAccent
healthMessage := "All systems operating normally"
totalActive := 0
totalCritical := 0
totalWarning := 0
for _, rd := range data.Resources {
for _, alert := range rd.Alerts {
if alert.ResolvedTime == nil {
totalActive++
if alert.Level == "critical" {
totalCritical++
} else {
totalWarning++
}
}
}
}
if totalCritical > 0 {
healthStatus = "CRITICAL"
healthColor = colorDanger
healthMessage = fmt.Sprintf("%d critical issues across fleet", totalCritical)
} else if totalWarning > 0 {
healthStatus = "WARNING"
healthColor = colorWarning
healthMessage = fmt.Sprintf("%d warnings across fleet", totalWarning)
}
// Health status card
cardX := 20.0
cardWidth := pageWidth - 40
cardHeight := 30.0
pdf.SetFillColor(healthColor[0], healthColor[1], healthColor[2])
pdf.RoundedRect(cardX, pdf.GetY(), cardWidth, cardHeight, 3, "1234", "F")
pdf.SetXY(cardX, pdf.GetY()+6)
pdf.SetFont("Arial", "B", 20)
pdf.SetTextColor(255, 255, 255)
pdf.CellFormat(cardWidth, 10, healthStatus, "", 1, "C", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.CellFormat(cardWidth, 7, healthMessage, "", 1, "C", false, 0, "")
pdf.SetY(pdf.GetY() + 12)
// Summary table
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Resource Summary", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
colWidths := []float64{40, 25, 20, 23, 23, 23, 16}
headers := []string{"Resource", "Type", "Status", "Avg CPU", "Avg Mem", "Avg Disk", "Alerts"}
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
for i, header := range headers {
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 8)
fill := false
// Track highest values for observations
var highestCPUName string
var highestCPUVal float64
var mostAlertsName string
var mostAlertsCount int
for _, rd := range data.Resources {
if fill {
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
// Resource name
resourceName := rd.ResourceID
if rd.Resource != nil && rd.Resource.Name != "" {
resourceName = rd.Resource.Name
}
if len(resourceName) > 25 {
resourceName = resourceName[:22] + "..."
}
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(colWidths[0], 6, resourceName, "1", 0, "L", fill, 0, "")
// Type
pdf.CellFormat(colWidths[1], 6, GetResourceTypeDisplayName(rd.ResourceType), "1", 0, "C", fill, 0, "")
// Status
status := "N/A"
if rd.Resource != nil {
status = rd.Resource.Status
}
if status == "online" || status == "running" {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
} else if status == "stopped" || status == "offline" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
pdf.CellFormat(colWidths[2], 6, status, "1", 0, "C", fill, 0, "")
// Avg CPU
var avgCPU float64
if stats, ok := rd.Summary.ByMetric["cpu"]; ok {
avgCPU = stats.Avg
}
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
pdf.CellFormat(colWidths[3], 6, fmt.Sprintf("%.1f%%", avgCPU), "1", 0, "C", fill, 0, "")
if avgCPU > highestCPUVal {
highestCPUVal = avgCPU
if rd.Resource != nil && rd.Resource.Name != "" {
highestCPUName = rd.Resource.Name
} else {
highestCPUName = rd.ResourceID
}
}
// Avg Memory
var avgMem float64
if stats, ok := rd.Summary.ByMetric["memory"]; ok {
avgMem = stats.Avg
}
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
pdf.CellFormat(colWidths[4], 6, fmt.Sprintf("%.1f%%", avgMem), "1", 0, "C", fill, 0, "")
// Avg Disk
var avgDisk float64
if stats, ok := rd.Summary.ByMetric["disk"]; ok {
avgDisk = stats.Avg
} else if stats, ok := rd.Summary.ByMetric["usage"]; ok {
avgDisk = stats.Avg
}
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
pdf.CellFormat(colWidths[5], 6, fmt.Sprintf("%.1f%%", avgDisk), "1", 0, "C", fill, 0, "")
// Alerts count
alertCount := 0
for _, alert := range rd.Alerts {
if alert.ResolvedTime == nil {
alertCount++
}
}
pdf.SetTextColor(getAlertCountColor(alertCount)[0], getAlertCountColor(alertCount)[1], getAlertCountColor(alertCount)[2])
pdf.CellFormat(colWidths[6], 6, fmt.Sprintf("%d", alertCount), "1", 0, "C", fill, 0, "")
if alertCount > mostAlertsCount {
mostAlertsCount = alertCount
if rd.Resource != nil && rd.Resource.Name != "" {
mostAlertsName = rd.Resource.Name
} else {
mostAlertsName = rd.ResourceID
}
}
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(8)
// Fleet narrative section. When data.FleetNarrative is set (AI or
// heuristic) it owns the prose, outlier list, patterns, and
// recommendations rendered here. The legacy highest-CPU /
// most-alerts bullets are rendered as a fallback when no narrative
// has been attached, preserving the prior multi-report behaviour
// for callers that bypass the engine wiring.
if data.FleetNarrative != nil {
writeFleetNarrativeSection(pdf, data.FleetNarrative)
return
}
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Fleet Observations", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
if highestCPUName != "" {
pdf.SetFillColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.CellFormat(0, 6, fmt.Sprintf("Highest CPU: %s (avg %.1f%%)", highestCPUName, highestCPUVal), "", 1, "L", false, 0, "")
pdf.Ln(1)
}
if mostAlertsCount > 0 && mostAlertsName != "" {
pdf.SetFillColor(colorDanger[0], colorDanger[1], colorDanger[2])
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.CellFormat(0, 6, fmt.Sprintf("Most alerts: %s (%d active)", mostAlertsName, mostAlertsCount), "", 1, "L", false, 0, "")
pdf.Ln(1)
}
if totalActive == 0 {
pdf.SetFillColor(colorAccent[0], colorAccent[1], colorAccent[2])
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.CellFormat(0, 6, "No active alerts across the fleet", "", 1, "L", false, 0, "")
}
}
// writeFleetNarrativeSection renders the fleet-level narrative produced
// by either the heuristic or AI fleet narrator. Layout mirrors the
// single-resource executive summary but is scoped to fleet semantics:
// outliers point at named resources, patterns describe cross-cutting
// trends, and the period-comparison and provenance footers keep the
// AI/heuristic distinction visible.
func writeFleetNarrativeSection(pdf *fpdf.Fpdf, fn *FleetNarrative) {
if fn == nil {
return
}
pageWidth, _ := pdf.GetPageSize()
bodyWidth := pageWidth - 40
if summary := strings.TrimSpace(fn.ExecutiveSummary); summary != "" {
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.MultiCell(bodyWidth, 5, summary, "", "L", false)
pdf.Ln(3)
}
if len(fn.Outliers) > 0 {
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Resources to investigate", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 10)
for _, o := range fn.Outliers {
color := bulletColor(o.Severity)
pdf.SetFillColor(color[0], color[1], color[2])
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
label := o.ResourceName
if label == "" {
label = o.ResourceID
}
pdf.CellFormat(0, 6, fmt.Sprintf("%s — %s", label, o.Reason), "", 1, "L", false, 0, "")
pdf.Ln(1)
}
}
if len(fn.Patterns) > 0 {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Cross-cutting patterns", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 10)
for _, b := range fn.Patterns {
color := bulletColor(b.Severity)
pdf.SetFillColor(color[0], color[1], color[2])
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
pdf.SetX(pdf.GetX() + 8)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 6, b.Text, "", 1, "L", false, 0, "")
pdf.Ln(1)
}
}
if len(fn.Recommendations) > 0 {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Recommended Actions", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 9)
for i, rec := range fn.Recommendations {
if i >= 5 {
break
}
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
pdf.CellFormat(6, 5, fmt.Sprintf("%d.", i+1), "", 0, "L", false, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, rec, "", 1, "L", false, 0, "")
pdf.Ln(1)
}
}
if comparison := strings.TrimSpace(fn.PeriodComparison); comparison != "" {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 8, "Period-over-period changes", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Arial", "", 9)
pdf.MultiCell(bodyWidth, 5, comparison, "", "L", false)
}
if disclaimer := strings.TrimSpace(fn.Disclaimer); disclaimer != "" {
pdf.Ln(4)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.MultiCell(bodyWidth, 4, disclaimer, "", "L", false)
} else if fn.Source == NarrativeSourceHeuristic {
// Same discoverability nudge as the single-resource path: a
// heuristic fleet narrative is functional but doesn't surface
// the AI capability that would have produced this section.
// Without the nudge, the user has no signal that fleet AI
// synthesis is a thing they could enable.
pdf.Ln(4)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.MultiCell(bodyWidth, 4,
"Tip: Configure Pulse Assistant in Settings to add AI-narrated fleet synthesis with named outliers, cross-cutting patterns, and period-over-period comparison to future reports.",
"", "L", false)
}
}
// writeCondensedResourcePage writes a condensed single-page view for one resource.
func (g *PDFGenerator) writeCondensedResourcePage(pdf *fpdf.Fpdf, rd *ReportData) {
// Resource header
resourceName := rd.ResourceID
if rd.Resource != nil && rd.Resource.Name != "" {
resourceName = rd.Resource.Name
}
// Name - measure width while font is still set to bold
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
nameWidth := pdf.GetStringWidth(resourceName)
pdf.CellFormat(nameWidth+2, 8, resourceName, "", 0, "L", false, 0, "")
// Type/status/uptime inline after the name
typeDisplay := GetResourceTypeDisplayName(rd.ResourceType)
status := "unknown"
if rd.Resource != nil {
status = rd.Resource.Status
}
pdf.SetFont("Arial", "", 10)
if status == "online" || status == "running" {
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
} else if status == "stopped" || status == "offline" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
statusStr := fmt.Sprintf(" | %s | %s", typeDisplay, status)
// Uptime
if rd.Resource != nil && rd.Resource.Uptime > 0 {
statusStr += fmt.Sprintf(" | Uptime: %s", formatUptime(rd.Resource.Uptime))
}
pdf.CellFormat(0, 8, statusStr, "", 1, "L", false, 0, "")
pdf.Ln(3)
// Stats bar - CPU, Memory, Disk averages and maxes
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
barY := pdf.GetY()
barWidth := 170.0
barHeight := 22.0
pdf.RoundedRect(20, barY, barWidth, barHeight, 2, "1234", "FD")
colW := barWidth / 3.0
// CPU stats
var avgCPU, maxCPU, avgMem, maxMem, avgDisk, maxDisk float64
if stats, ok := rd.Summary.ByMetric["cpu"]; ok {
avgCPU = stats.Avg
maxCPU = stats.Max
}
if stats, ok := rd.Summary.ByMetric["memory"]; ok {
avgMem = stats.Avg
maxMem = stats.Max
}
if stats, ok := rd.Summary.ByMetric["disk"]; ok {
avgDisk = stats.Avg
maxDisk = stats.Max
} else if stats, ok := rd.Summary.ByMetric["usage"]; ok {
avgDisk = stats.Avg
maxDisk = stats.Max
}
// CPU column
pdf.SetXY(20+2, barY+3)
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
pdf.CellFormat(colW-4, 5, "CPU", "", 0, "C", false, 0, "")
pdf.SetXY(20+2, barY+9)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgCPU, maxCPU), "", 0, "C", false, 0, "")
// Memory column
pdf.SetXY(20+colW+2, barY+3)
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor([3]int{155, 89, 182}[0], [3]int{155, 89, 182}[1], [3]int{155, 89, 182}[2])
pdf.CellFormat(colW-4, 5, "Memory", "", 0, "C", false, 0, "")
pdf.SetXY(20+colW+2, barY+9)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgMem, maxMem), "", 0, "C", false, 0, "")
// Disk column
pdf.SetXY(20+2*colW+2, barY+3)
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
pdf.CellFormat(colW-4, 5, "Disk", "", 0, "C", false, 0, "")
pdf.SetXY(20+2*colW+2, barY+9)
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgDisk, maxDisk), "", 0, "C", false, 0, "")
pdf.SetY(barY + barHeight + 5)
// Small chart: CPU + Memory overlaid (if we have data)
cpuPoints := rd.Metrics["cpu"]
memPoints := rd.Metrics["memory"]
if len(cpuPoints) >= 2 || len(memPoints) >= 2 {
chartHeight := 40.0
chartWidth := 170.0
chartX := 20.0
chartY := pdf.GetY()
// Use CPU data primarily, or memory if no CPU
primaryPoints := cpuPoints
if len(primaryPoints) < 2 {
primaryPoints = memPoints
}
if len(primaryPoints) >= 2 {
// Chart title
pdf.SetFont("Arial", "B", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, "Performance Overview", "", 1, "L", false, 0, "")
chartY = pdf.GetY()
g.drawChart(pdf, primaryPoints, chartX, chartY, chartWidth, chartHeight, "cpu")
// If we have both CPU and memory, overlay memory
if len(cpuPoints) >= 2 && len(memPoints) >= 2 {
g.drawChartOverlay(pdf, memPoints, cpuPoints, chartX, chartY, chartWidth, chartHeight)
}
// Legend (below chart X-axis labels which are at chartY + chartHeight + 1..+5)
pdf.SetY(chartY + chartHeight + 7)
pdf.SetFont("Arial", "", 7)
if len(cpuPoints) >= 2 {
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
pdf.CellFormat(30, 4, "--- CPU", "", 0, "L", false, 0, "")
}
if len(memPoints) >= 2 {
pdf.SetTextColor(155, 89, 182)
pdf.CellFormat(30, 4, "--- Memory", "", 0, "L", false, 0, "")
}
pdf.Ln(6)
}
}
// Active alerts (up to 3)
activeAlerts := make([]AlertInfo, 0)
for _, alert := range rd.Alerts {
if alert.ResolvedTime == nil {
activeAlerts = append(activeAlerts, alert)
}
}
if len(activeAlerts) > 0 {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 6, "Active Alerts", "", 1, "L", false, 0, "")
pdf.Ln(1)
pdf.SetFont("Arial", "", 9)
maxAlerts := 3
if len(activeAlerts) < maxAlerts {
maxAlerts = len(activeAlerts)
}
for i := 0; i < maxAlerts; i++ {
alert := activeAlerts[i]
if alert.Level == "critical" {
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
} else {
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
}
pdf.CellFormat(6, 5, "!", "", 0, "C", false, 0, "")
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
msg := alert.Message
if len(msg) > 80 {
msg = msg[:77] + "..."
}
pdf.CellFormat(0, 5, msg, "", 1, "L", false, 0, "")
}
if len(activeAlerts) > 3 {
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pdf.CellFormat(0, 5, fmt.Sprintf("... and %d more", len(activeAlerts)-3), "", 1, "L", false, 0, "")
}
}
// Storage summary (nodes) or backup summary (VMs/containers)
if len(rd.Storage) > 0 {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 6, "Storage Pools", "", 1, "L", false, 0, "")
pdf.Ln(1)
pdf.SetFont("Arial", "", 9)
for _, s := range rd.Storage {
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
line := fmt.Sprintf("%s (%s): %s / %s (%.1f%%)",
s.Name, s.Type,
formatBytes(float64(s.Used)),
formatBytes(float64(s.Total)),
s.UsagePerc)
pdf.CellFormat(0, 5, line, "", 1, "L", false, 0, "")
}
}
if len(rd.Backups) > 0 {
pdf.Ln(3)
pdf.SetFont("Arial", "B", 10)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 6, "Backups", "", 1, "L", false, 0, "")
pdf.Ln(1)
pdf.SetFont("Arial", "", 9)
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
pdf.CellFormat(0, 5, fmt.Sprintf("%d backups available", len(rd.Backups)), "", 1, "L", false, 0, "")
if len(rd.Backups) > 0 {
latest := rd.Backups[0]
for _, b := range rd.Backups {
if b.Timestamp.After(latest.Timestamp) {
latest = b
}
}
pdf.CellFormat(0, 5, fmt.Sprintf("Latest: %s (%s)", latest.Timestamp.Format("2006-01-02 15:04"), formatBytes(float64(latest.Size))), "", 1, "L", false, 0, "")
}
}
}
// drawChartOverlay draws a secondary line on an existing chart using memory data over CPU scale.
func (g *PDFGenerator) drawChartOverlay(pdf *fpdf.Fpdf, overlayPoints []MetricDataPoint, primaryPoints []MetricDataPoint, x, y, width, height float64) {
if len(overlayPoints) < 2 || len(primaryPoints) < 2 {
return
}
// Use the same time scale as the primary chart
startTime := primaryPoints[0].Timestamp.Unix()
endTime := primaryPoints[len(primaryPoints)-1].Timestamp.Unix()
timeRange := float64(endTime - startTime)
if timeRange == 0 {
timeRange = 1
}
// Use the primary chart's value range for consistent scaling
minVal, maxVal := primaryPoints[0].Value, primaryPoints[0].Value
for _, p := range primaryPoints {
if p.Value < minVal {
minVal = p.Value
}
if p.Value > maxVal {
maxVal = p.Value
}
}
// Also include overlay points in the range
for _, p := range overlayPoints {
if p.Value < minVal {
minVal = p.Value
}
if p.Value > maxVal {
maxVal = p.Value
}
}
valRange := maxVal - minVal
if valRange < 1 {
valRange = 10
}
minVal = math.Max(0, minVal-valRange*0.1)
maxVal = maxVal + valRange*0.1
// Draw the overlay line in purple (memory color)
memColor := [3]int{155, 89, 182}
pdf.SetDrawColor(memColor[0], memColor[1], memColor[2])
pdf.SetLineWidth(0.6)
prevX, prevY := 0.0, 0.0
for i, p := range overlayPoints {
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
if i > 0 {
pdf.Line(prevX, prevY, xPos, yPos)
}
prevX, prevY = xPos, yPos
}
}
// addMultiPageNumbers adds page numbers to all pages except the first (cover).
func (g *PDFGenerator) addMultiPageNumbers(pdf *fpdf.Fpdf) {
pdf.SetAutoPageBreak(false, 0)
totalPages := pdf.PageCount()
for i := 2; i <= totalPages; i++ {
pdf.SetPage(i)
pageWidth, pageHeight := pdf.GetPageSize()
pdf.SetY(pageHeight - 15)
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
pageNum := i - 1
totalContent := totalPages - 1
pdf.CellFormat(0, 5, fmt.Sprintf("Page %d of %d", pageNum, totalContent), "", 0, "C", false, 0, "")
// Bottom line
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
pdf.SetLineWidth(0.3)
pdf.Line(20, pageHeight-20, pageWidth-20, pageHeight-20)
}
}
// getMetricColor returns a color for a metric type.
func getMetricColor(metricType string) [3]int {
switch metricType {
case "cpu":
return colorSecondary // Blue
case "memory":
return [3]int{155, 89, 182} // Purple
case "disk", "usage":
return colorAccent // Green
default:
return colorSecondary
}
}
// formatDuration formats a duration in human-readable form.
func formatDuration(d time.Duration) string {
hours := int(d.Hours())
if hours >= 24 {
days := hours / 24
remainingHours := hours % 24
dayWord := "days"
if days == 1 {
dayWord = "day"
}
if remainingHours > 0 {
hourWord := "hours"
if remainingHours == 1 {
hourWord = "hour"
}
return fmt.Sprintf("%d %s, %d %s", days, dayWord, remainingHours, hourWord)
}
return fmt.Sprintf("%d %s", days, dayWord)
}
if hours > 0 {
minutes := int(d.Minutes()) % 60
hourWord := "hours"
if hours == 1 {
hourWord = "hour"
}
if minutes > 0 {
minWord := "minutes"
if minutes == 1 {
minWord = "minute"
}
return fmt.Sprintf("%d %s, %d %s", hours, hourWord, minutes, minWord)
}
return fmt.Sprintf("%d %s", hours, hourWord)
}
minutes := int(d.Minutes())
minWord := "minutes"
if minutes == 1 {
minWord = "minute"
}
return fmt.Sprintf("%d %s", minutes, minWord)
}