mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-20 18:23:47 +00:00
d4463a615c
The single-resource AI narrative landed in b2bd9d114 but multi-resource
fleet reports stayed heuristic-only. That left a gap on the exact axis
where AI helps most: a 50-resource fleet PDF is where synthesis is the
difference between useful and unread.
Introduce FleetNarrator as a separate interface from Narrator. The
input shapes are different — single-resource takes one set of metric
stats with a prior window, fleet takes a denormalised cross-resource
view with per-resource summaries plus a fleet aggregate.
HeuristicFleetNarrator owns the deterministic fallback: ranks
resources by severity (critical alerts > unhealthy disks > storage
pressure > memory > CPU > non-critical alerts), picks up to 5
outliers, derives cross-cutting patterns by counting how many of N
resources share a hot signal, and emits fleet-scoped recommendations.
internal/ai.Service implements FleetNarrator through
report_fleet_narrator.go. Distinct use-case label
(report_narrative_fleet) so fleet vs single-resource spend is
separable in the cost ledger and budget gate. The fleet payload is
denormalised through buildReportFleetPayload so prompt cost scales
linearly with fleet size. Same fail-closed invariant — nil provider,
parse failure, or context cancellation falls through to the heuristic.
Single-resource Narrator is intentionally NOT propagated through
engine.GenerateMulti: a 50-resource fleet report performs one AI call
(fleet narrator), not 51. The router resolver returns the AI service
for all three roles (Narrator, FleetNarrator, FindingsProvider).
The fleet PDF renders the FleetNarrative in the fleet summary cover
when present: executive prose, named outliers with severity-coloured
bullets, cross-cutting patterns, recommendations, optional period
comparison, and an AI provenance footer. The deterministic resource
summary table is preserved above so every named outlier is verifiable
against the table immediately below it. Legacy "Highest CPU / Most
alerts" bullets remain as the fallback when no FleetNarrative is
attached.
2382 lines
73 KiB
Go
2382 lines
73 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|