Files
Pulse/pkg/reporting/pdf.go
rcourtman 5c1487e406 feat: add resource picker and multi-resource report generation
Replace manual resource ID entry with a searchable, filterable resource
picker that uses live WebSocket state. Support selecting multiple
resources (up to 50) for combined fleet reports.

Multi-resource PDFs include a cover page, fleet summary table with
aggregate health status, and condensed per-resource detail pages with
overlaid CPU/memory charts. Multi-resource CSVs include a summary
section followed by interleaved time-series data with resource columns.

New POST /api/admin/reports/generate-multi endpoint handles multi-resource
requests while the existing single-resource GET endpoint remains unchanged.

Also fixes resource ID validation regex to allow colons used in
VM/container IDs (e.g., "instance:node:vmid").
2026-02-04 10:24:23 +00:00

2391 lines
72 KiB
Go

package reporting
import (
"bytes"
"fmt"
"math"
"sort"
"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)
}
}
// 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)
// 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)
}
}
pdf.Ln(10)
}
// observation represents a key observation for the executive summary
type observation struct {
icon string
text string
color [3]int
}
// generateObservations analyzes the data and generates key observations
func (g *PDFGenerator) generateObservations(data *ReportData) []observation {
var obs []observation
// Analyze CPU
if stats, ok := data.Summary.ByMetric["cpu"]; ok {
if stats.Max > 90 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("CPU peaked at %.1f%% - potential capacity constraint", stats.Max),
color: colorDanger,
})
} else if stats.Avg < 20 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("CPU averaging %.1f%% - resource is underutilized", stats.Avg),
color: colorAccent,
})
} else {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("CPU usage normal (avg %.1f%%, max %.1f%%)", stats.Avg, stats.Max),
color: colorAccent,
})
}
}
// Analyze Memory
if stats, ok := data.Summary.ByMetric["memory"]; ok {
if stats.Avg > 85 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Memory consistently high at %.1f%% avg - consider scaling", stats.Avg),
color: colorDanger,
})
} else if stats.Max > 95 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Memory peaked at %.1f%% - near capacity", stats.Max),
color: colorWarning,
})
} else {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Memory usage healthy (avg %.1f%%)", stats.Avg),
color: colorAccent,
})
}
}
// Analyze Disk
diskKey := "disk"
if _, hasDisk := data.Summary.ByMetric["disk"]; !hasDisk {
if _, hasUsage := data.Summary.ByMetric["usage"]; hasUsage {
diskKey = "usage"
}
}
if stats, ok := data.Summary.ByMetric[diskKey]; ok {
if stats.Avg > 85 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Disk at %.1f%% - plan capacity expansion", stats.Avg),
color: colorDanger,
})
} else if stats.Avg > 70 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Disk at %.1f%% - monitor growth trend", stats.Avg),
color: colorWarning,
})
} else {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Disk usage acceptable at %.1f%%", stats.Avg),
color: colorAccent,
})
}
}
// Alert summary
resolved := 0
for _, alert := range data.Alerts {
if alert.ResolvedTime != nil {
resolved++
}
}
if resolved > 0 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("%d alerts were triggered and resolved during this period", resolved),
color: colorSecondary,
})
}
// Physical disk health check (WearLevel = SSD life remaining, 100% = healthy, 0% = end of life)
for _, disk := range data.Disks {
if disk.WearLevel > 0 && disk.WearLevel <= 10 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("CRITICAL: Disk %s has only %d%% life remaining - replace immediately", disk.Device, disk.WearLevel),
color: colorDanger,
})
} else if disk.WearLevel > 0 && disk.WearLevel <= 30 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("Disk %s has %d%% life remaining - plan replacement", disk.Device, disk.WearLevel),
color: colorWarning,
})
}
// Check disk health
if disk.Health == "FAILED" {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("CRITICAL: Disk %s SMART health check FAILED", disk.Device),
color: colorDanger,
})
}
}
// Uptime observation
if data.Resource != nil && data.Resource.Uptime > 0 {
uptimeDays := data.Resource.Uptime / 86400
if uptimeDays > 90 {
obs = append(obs, observation{
icon: "-",
text: fmt.Sprintf("System uptime: %d days - consider scheduling maintenance", uptimeDays),
color: colorWarning,
})
}
}
// If no observations, add a default one
if len(obs) == 0 {
obs = append(obs, observation{
icon: "-",
text: "Insufficient data for detailed analysis",
color: colorTextMuted,
})
}
return obs
}
// 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 creates actionable recommendations based on data
func (g *PDFGenerator) generateRecommendations(data *ReportData, criticalAlerts, warningAlerts int) []string {
var recs []string
// Critical disk health - highest priority (WearLevel = life remaining, 100% = healthy)
for _, disk := range data.Disks {
if disk.WearLevel > 0 && disk.WearLevel <= 10 {
recs = append(recs, fmt.Sprintf("Replace disk %s immediately (only %d%% life remaining)", disk.Device, disk.WearLevel))
} else if disk.WearLevel > 0 && disk.WearLevel <= 30 {
recs = append(recs, fmt.Sprintf("Schedule replacement for disk %s within 3-6 months (%d%% life remaining)", disk.Device, disk.WearLevel))
}
if disk.Health == "FAILED" {
recs = append(recs, fmt.Sprintf("Investigate and replace disk %s - SMART health check failed", disk.Device))
}
}
// Critical alerts need attention
if criticalAlerts > 0 {
recs = append(recs, "Investigate and resolve critical alerts immediately")
}
// High resource usage
if stats, ok := data.Summary.ByMetric["memory"]; ok {
if stats.Avg > 85 {
recs = append(recs, "Consider adding memory or optimizing memory-intensive workloads")
}
}
if stats, ok := data.Summary.ByMetric["cpu"]; ok {
if stats.Max > 90 {
recs = append(recs, "Review CPU-intensive processes during peak usage periods")
}
}
// Disk space
diskKey := "disk"
if _, ok := data.Summary.ByMetric["disk"]; !ok {
diskKey = "usage"
}
if stats, ok := data.Summary.ByMetric[diskKey]; ok {
if stats.Avg > 85 {
recs = append(recs, "Clean up disk space or expand storage capacity")
}
}
// Storage pool warnings
for _, storage := range data.Storage {
if storage.UsagePerc >= 90 {
recs = append(recs, fmt.Sprintf("Expand storage pool '%s' (currently at %.0f%% capacity)", storage.Name, storage.UsagePerc))
}
}
// Long uptime
if data.Resource != nil && data.Resource.Uptime > 0 {
uptimeDays := data.Resource.Uptime / 86400
if uptimeDays > 90 {
recs = append(recs, "Schedule maintenance window to apply pending updates and reboot")
}
}
// Underutilization suggestion
if stats, ok := data.Summary.ByMetric["cpu"]; ok {
if stats.Avg < 10 && len(recs) == 0 {
recs = append(recs, "System is underutilized - consider consolidating workloads")
}
}
// Default good state message
if len(recs) == 0 {
recs = append(recs, "No immediate action required - continue monitoring")
}
return recs
}
// 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 rd.ResourceType {
case "node":
nodeCount++
case "vm":
vmCount++
case "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 Observations
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, "")
}
}
// 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)
}