feat(reporting): add advanced reporting engine with CSV/PDF export

- Add reporting engine for scheduled and on-demand reports
- Implement CSV export functionality
- Implement PDF report generation
This commit is contained in:
rcourtman
2026-01-12 15:21:28 +00:00
parent 2fdf7906e6
commit 3199c258d2
4 changed files with 1226 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
package reporting
import (
"bytes"
"encoding/csv"
"fmt"
"sort"
"time"
)
// CSVGenerator handles CSV report generation.
type CSVGenerator struct{}
// NewCSVGenerator creates a new CSV generator.
func NewCSVGenerator() *CSVGenerator {
return &CSVGenerator{}
}
// Generate creates a CSV report from the provided data.
func (g *CSVGenerator) Generate(data *ReportData) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
// Write header comment rows
if err := g.writeHeader(w, data); err != nil {
return nil, err
}
// Write summary section
if err := g.writeSummary(w, data); err != nil {
return nil, err
}
// Write data section
if err := g.writeData(w, data); err != nil {
return nil, err
}
w.Flush()
if err := w.Error(); err != nil {
return nil, fmt.Errorf("CSV write error: %w", err)
}
return buf.Bytes(), nil
}
// writeHeader writes the report header information.
func (g *CSVGenerator) writeHeader(w *csv.Writer, data *ReportData) error {
headers := [][]string{
{"# Pulse Metrics Report"},
{"# Title:", data.Title},
{"# Resource Type:", GetResourceTypeDisplayName(data.ResourceType)},
{"# Resource ID:", data.ResourceID},
{"# Period:", fmt.Sprintf("%s to %s", data.Start.Format(time.RFC3339), data.End.Format(time.RFC3339))},
{"# Generated:", data.GeneratedAt.Format(time.RFC3339)},
{"# Total Data Points:", fmt.Sprintf("%d", data.TotalPoints)},
{""}, // Empty row as separator
}
for _, row := range headers {
if err := w.Write(row); err != nil {
return err
}
}
return nil
}
// writeSummary writes the metrics summary section.
func (g *CSVGenerator) writeSummary(w *csv.Writer, data *ReportData) error {
// Section header
if err := w.Write([]string{"# SUMMARY"}); err != nil {
return err
}
// Column headers
if err := w.Write([]string{"Metric", "Count", "Min", "Max", "Average", "Current", "Unit"}); err != nil {
return err
}
// Get sorted metric names for consistent output
metricNames := make([]string, 0, len(data.Summary.ByMetric))
for name := range data.Summary.ByMetric {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
// Write summary rows
for _, metricType := range metricNames {
stats := data.Summary.ByMetric[metricType]
unit := GetMetricUnit(metricType)
row := []string{
GetMetricTypeDisplayName(metricType),
fmt.Sprintf("%d", stats.Count),
formatValue(stats.Min, unit),
formatValue(stats.Max, unit),
formatValue(stats.Avg, unit),
formatValue(stats.Current, unit),
unit,
}
if err := w.Write(row); err != nil {
return err
}
}
// Empty row as separator
if err := w.Write([]string{""}); err != nil {
return err
}
return nil
}
// writeData writes the detailed metrics data section.
func (g *CSVGenerator) writeData(w *csv.Writer, data *ReportData) error {
// Section header
if err := w.Write([]string{"# DATA"}); err != nil {
return err
}
// Get sorted metric names
metricNames := make([]string, 0, len(data.Metrics))
for name := range data.Metrics {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
// Build header row with timestamp + all metrics
headerRow := []string{"Timestamp"}
for _, name := range metricNames {
unit := GetMetricUnit(name)
if unit != "" {
headerRow = append(headerRow, fmt.Sprintf("%s (%s)", GetMetricTypeDisplayName(name), unit))
} else {
headerRow = append(headerRow, GetMetricTypeDisplayName(name))
}
}
if err := w.Write(headerRow); err != nil {
return err
}
// Collect all unique timestamps and build a map for lookup
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
}
}
// Sort timestamps
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] })
// Write data rows
for _, ts := range timestamps {
t := time.Unix(ts, 0)
row := []string{t.Format(time.RFC3339)}
for _, metricName := range metricNames {
if val, ok := metricsByTime[metricName][ts]; ok {
row = append(row, fmt.Sprintf("%.2f", val))
} else {
row = append(row, "") // Missing data point
}
}
if err := w.Write(row); err != nil {
return err
}
}
return nil
}
// formatValue formats a metric value with appropriate precision.
func formatValue(value float64, unit string) string {
if unit == "bytes" {
return formatBytes(value)
}
return fmt.Sprintf("%.2f", value)
}
// formatBytes converts bytes to human-readable format.
func formatBytes(bytes float64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%.0f B", bytes)
}
div, exp := float64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %ciB", bytes/div, "KMGTPE"[exp])
}
+243
View File
@@ -0,0 +1,243 @@
package reporting
import (
"fmt"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rs/zerolog/log"
)
// ReportEngine implements the reporting.Engine interface with
// full CSV and PDF generation capabilities.
type ReportEngine struct {
metricsStore *metrics.Store
csvGen *CSVGenerator
pdfGen *PDFGenerator
}
// EngineConfig holds configuration for the report engine.
type EngineConfig struct {
MetricsStore *metrics.Store
}
// NewReportEngine creates a new reporting engine.
func NewReportEngine(cfg EngineConfig) *ReportEngine {
return &ReportEngine{
metricsStore: cfg.MetricsStore,
csvGen: NewCSVGenerator(),
pdfGen: NewPDFGenerator(),
}
}
// Generate creates a report in the specified format.
func (e *ReportEngine) Generate(req MetricReportRequest) (data []byte, contentType string, err error) {
if e.metricsStore == nil {
return nil, "", fmt.Errorf("metrics store not initialized")
}
// Query metrics data
reportData, err := e.queryMetrics(req)
if err != nil {
return nil, "", fmt.Errorf("failed to query metrics: %w", err)
}
log.Debug().
Str("resourceType", req.ResourceType).
Str("resourceID", req.ResourceID).
Str("format", string(req.Format)).
Int("dataPoints", reportData.TotalPoints).
Msg("Generating report")
switch req.Format {
case FormatCSV:
data, err = e.csvGen.Generate(reportData)
if err != nil {
return nil, "", fmt.Errorf("CSV generation failed: %w", err)
}
contentType = "text/csv"
case FormatPDF:
data, err = e.pdfGen.Generate(reportData)
if err != nil {
return nil, "", fmt.Errorf("PDF generation failed: %w", err)
}
contentType = "application/pdf"
default:
return nil, "", fmt.Errorf("unsupported format: %s", req.Format)
}
return data, contentType, nil
}
// ReportData holds the data for report generation.
type ReportData struct {
Title string
ResourceType string
ResourceID string
Start time.Time
End time.Time
GeneratedAt time.Time
Metrics map[string][]MetricDataPoint
TotalPoints int
Summary MetricSummary
}
// MetricDataPoint represents a single data point in a report.
type MetricDataPoint struct {
Timestamp time.Time
Value float64
Min float64
Max float64
}
// MetricSummary holds aggregated statistics for a report.
type MetricSummary struct {
ByMetric map[string]MetricStats
}
// MetricStats holds statistics for a single metric type.
type MetricStats struct {
MetricType string
Count int
Min float64
Max float64
Avg float64
Current float64
}
// queryMetrics fetches metrics from the store and prepares report data.
func (e *ReportEngine) queryMetrics(req MetricReportRequest) (*ReportData, error) {
data := &ReportData{
Title: req.Title,
ResourceType: req.ResourceType,
ResourceID: req.ResourceID,
Start: req.Start,
End: req.End,
GeneratedAt: time.Now(),
Metrics: make(map[string][]MetricDataPoint),
Summary: MetricSummary{
ByMetric: make(map[string]MetricStats),
},
}
if data.Title == "" {
data.Title = fmt.Sprintf("%s Report: %s", req.ResourceType, req.ResourceID)
}
var metricsMap map[string][]metrics.MetricPoint
var err error
if req.MetricType != "" {
// Query specific metric
points, queryErr := e.metricsStore.Query(req.ResourceType, req.ResourceID, req.MetricType, req.Start, req.End)
if queryErr != nil {
return nil, queryErr
}
metricsMap = map[string][]metrics.MetricPoint{
req.MetricType: points,
}
} else {
// Query all metrics for the resource
metricsMap, err = e.metricsStore.QueryAll(req.ResourceType, req.ResourceID, req.Start, req.End)
if err != nil {
return nil, err
}
}
// Convert to report format and calculate statistics
for metricType, points := range metricsMap {
if len(points) == 0 {
continue
}
dataPoints := make([]MetricDataPoint, len(points))
var sum float64
stats := MetricStats{
MetricType: metricType,
Count: len(points),
Min: points[0].Value,
Max: points[0].Value,
}
for i, p := range points {
dataPoints[i] = MetricDataPoint{
Timestamp: p.Timestamp,
Value: p.Value,
Min: p.Min,
Max: p.Max,
}
sum += p.Value
if p.Value < stats.Min {
stats.Min = p.Value
}
if p.Value > stats.Max {
stats.Max = p.Value
}
}
stats.Avg = sum / float64(len(points))
stats.Current = points[len(points)-1].Value
data.TotalPoints += len(points)
data.Metrics[metricType] = dataPoints
data.Summary.ByMetric[metricType] = stats
}
return data, nil
}
// GetResourceTypeDisplayName returns a human-readable name for resource types.
func GetResourceTypeDisplayName(resourceType string) string {
switch resourceType {
case "node":
return "Node"
case "vm":
return "Virtual Machine"
case "container":
return "LXC Container"
case "dockerHost":
return "Docker Host"
case "dockerContainer":
return "Docker Container"
case "storage":
return "Storage"
default:
return resourceType
}
}
// GetMetricTypeDisplayName returns a human-readable name for metric types.
func GetMetricTypeDisplayName(metricType string) string {
switch metricType {
case "cpu":
return "CPU Usage"
case "memory":
return "Memory Usage"
case "disk":
return "Disk Usage"
case "usage":
return "Storage Usage"
case "used":
return "Used Space"
case "total":
return "Total Space"
case "avail":
return "Available Space"
default:
return metricType
}
}
// GetMetricUnit returns the unit for a metric type.
func GetMetricUnit(metricType string) string {
switch metricType {
case "cpu", "memory", "disk", "usage":
return "%"
case "used", "total", "avail":
return "bytes"
default:
return ""
}
}
+345
View File
@@ -0,0 +1,345 @@
package reporting
import (
"strings"
"testing"
"time"
)
func TestCSVGenerator_Generate(t *testing.T) {
data := createTestReportData()
gen := NewCSVGenerator()
result, err := gen.Generate(data)
if err != nil {
t.Fatalf("CSV generation failed: %v", err)
}
csv := string(result)
// Check header
if !strings.Contains(csv, "# Pulse Metrics Report") {
t.Error("Missing report header")
}
if !strings.Contains(csv, "Test Report") {
t.Error("Missing title")
}
if !strings.Contains(csv, "node") {
t.Error("Missing resource type")
}
if !strings.Contains(csv, "test-node-1") {
t.Error("Missing resource ID")
}
// Check summary section
if !strings.Contains(csv, "# SUMMARY") {
t.Error("Missing summary section")
}
if !strings.Contains(csv, "CPU Usage") {
t.Error("Missing CPU metric in summary")
}
// Check data section
if !strings.Contains(csv, "# DATA") {
t.Error("Missing data section")
}
if !strings.Contains(csv, "Timestamp") {
t.Error("Missing timestamp column header")
}
}
func TestPDFGenerator_Generate(t *testing.T) {
data := createTestReportData()
gen := NewPDFGenerator()
result, err := gen.Generate(data)
if err != nil {
t.Fatalf("PDF generation failed: %v", err)
}
// Check PDF magic bytes
if len(result) < 4 {
t.Fatal("PDF too short")
}
if string(result[:4]) != "%PDF" {
t.Error("Missing PDF magic bytes")
}
// Check reasonable size (should be at least a few KB)
if len(result) < 1000 {
t.Errorf("PDF seems too small: %d bytes", len(result))
}
}
func TestPDFGenerator_EmptyData(t *testing.T) {
data := &ReportData{
Title: "Empty Report",
ResourceType: "node",
ResourceID: "empty-node",
Start: time.Now().Add(-1 * time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Metrics: make(map[string][]MetricDataPoint),
Summary: MetricSummary{
ByMetric: make(map[string]MetricStats),
},
}
gen := NewPDFGenerator()
result, err := gen.Generate(data)
if err != nil {
t.Fatalf("PDF generation failed for empty data: %v", err)
}
if string(result[:4]) != "%PDF" {
t.Error("Missing PDF magic bytes for empty report")
}
}
func TestCSVGenerator_EmptyData(t *testing.T) {
data := &ReportData{
Title: "Empty Report",
ResourceType: "node",
ResourceID: "empty-node",
Start: time.Now().Add(-1 * time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Metrics: make(map[string][]MetricDataPoint),
Summary: MetricSummary{
ByMetric: make(map[string]MetricStats),
},
}
gen := NewCSVGenerator()
result, err := gen.Generate(data)
if err != nil {
t.Fatalf("CSV generation failed for empty data: %v", err)
}
csv := string(result)
if !strings.Contains(csv, "# Pulse Metrics Report") {
t.Error("Missing header in empty report")
}
}
func TestGetResourceTypeDisplayName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"node", "Node"},
{"vm", "Virtual Machine"},
{"container", "LXC Container"},
{"dockerHost", "Docker Host"},
{"dockerContainer", "Docker Container"},
{"storage", "Storage"},
{"unknown", "unknown"},
}
for _, tc := range tests {
result := GetResourceTypeDisplayName(tc.input)
if result != tc.expected {
t.Errorf("GetResourceTypeDisplayName(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestGetMetricTypeDisplayName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"cpu", "CPU Usage"},
{"memory", "Memory Usage"},
{"disk", "Disk Usage"},
{"usage", "Storage Usage"},
{"used", "Used Space"},
{"total", "Total Space"},
{"avail", "Available Space"},
{"unknown", "unknown"},
}
for _, tc := range tests {
result := GetMetricTypeDisplayName(tc.input)
if result != tc.expected {
t.Errorf("GetMetricTypeDisplayName(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestGetMetricUnit(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"cpu", "%"},
{"memory", "%"},
{"disk", "%"},
{"usage", "%"},
{"used", "bytes"},
{"total", "bytes"},
{"avail", "bytes"},
{"unknown", ""},
}
for _, tc := range tests {
result := GetMetricUnit(tc.input)
if result != tc.expected {
t.Errorf("GetMetricUnit(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
input float64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.00 KiB"},
{1536, "1.50 KiB"},
{1048576, "1.00 MiB"},
{1073741824, "1.00 GiB"},
{1099511627776, "1.00 TiB"},
}
for _, tc := range tests {
result := formatBytes(tc.input)
if result != tc.expected {
t.Errorf("formatBytes(%f) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestCSVGenerator_MultipleMetrics(t *testing.T) {
now := time.Now()
data := &ReportData{
Title: "Multi-Metric Test",
ResourceType: "node",
ResourceID: "node-1",
Start: now.Add(-1 * time.Hour),
End: now,
GeneratedAt: now,
Metrics: map[string][]MetricDataPoint{
"cpu": {
{Timestamp: now.Add(-30 * time.Minute), Value: 50.0},
{Timestamp: now, Value: 60.0},
},
"memory": {
{Timestamp: now.Add(-30 * time.Minute), Value: 70.0},
{Timestamp: now, Value: 75.0},
},
"disk": {
{Timestamp: now.Add(-30 * time.Minute), Value: 40.0},
{Timestamp: now, Value: 42.0},
},
},
Summary: MetricSummary{
ByMetric: map[string]MetricStats{
"cpu": {MetricType: "cpu", Count: 2, Min: 50, Max: 60, Avg: 55, Current: 60},
"memory": {MetricType: "memory", Count: 2, Min: 70, Max: 75, Avg: 72.5, Current: 75},
"disk": {MetricType: "disk", Count: 2, Min: 40, Max: 42, Avg: 41, Current: 42},
},
},
TotalPoints: 6,
}
gen := NewCSVGenerator()
result, err := gen.Generate(data)
if err != nil {
t.Fatalf("CSV generation failed: %v", err)
}
csv := string(result)
// Check all metrics are present in summary
if !strings.Contains(csv, "CPU Usage") {
t.Error("Missing CPU in summary")
}
if !strings.Contains(csv, "Memory Usage") {
t.Error("Missing Memory in summary")
}
if !strings.Contains(csv, "Disk Usage") {
t.Error("Missing Disk in summary")
}
// Check data rows
lines := strings.Split(csv, "\n")
dataStarted := false
dataRows := 0
for _, line := range lines {
if strings.HasPrefix(line, "# DATA") {
dataStarted = true
continue
}
if dataStarted && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "Timestamp") && line != "" {
dataRows++
}
}
// Should have data rows (timestamps may be merged or separate)
if dataRows == 0 {
t.Error("No data rows in CSV")
}
}
// createTestReportData creates sample report data for testing.
func createTestReportData() *ReportData {
now := time.Now()
start := now.Add(-1 * time.Hour)
// Create sample data points
cpuPoints := make([]MetricDataPoint, 12)
memPoints := make([]MetricDataPoint, 12)
for i := 0; i < 12; i++ {
ts := start.Add(time.Duration(i*5) * time.Minute)
cpuPoints[i] = MetricDataPoint{
Timestamp: ts,
Value: float64(50 + i*2),
Min: float64(48 + i*2),
Max: float64(52 + i*2),
}
memPoints[i] = MetricDataPoint{
Timestamp: ts,
Value: float64(60 + i),
Min: float64(58 + i),
Max: float64(62 + i),
}
}
return &ReportData{
Title: "Test Report",
ResourceType: "node",
ResourceID: "test-node-1",
Start: start,
End: now,
GeneratedAt: now,
Metrics: map[string][]MetricDataPoint{
"cpu": cpuPoints,
"memory": memPoints,
},
Summary: MetricSummary{
ByMetric: map[string]MetricStats{
"cpu": {
MetricType: "cpu",
Count: 12,
Min: 50,
Max: 72,
Avg: 61,
Current: 72,
},
"memory": {
MetricType: "memory",
Count: 12,
Min: 60,
Max: 71,
Avg: 65.5,
Current: 71,
},
},
},
TotalPoints: 24,
}
}
+435
View File
@@ -0,0 +1,435 @@
package reporting
import (
"bytes"
"fmt"
"math"
"sort"
"time"
"github.com/go-pdf/fpdf"
)
// 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(15, 15, 15)
pdf.SetAutoPageBreak(true, 15)
// Add first page
pdf.AddPage()
// Header
g.writeHeader(pdf, data)
// Summary section
g.writeSummary(pdf, data)
// Charts for each metric
g.writeCharts(pdf, data)
// Data table
g.writeDataTable(pdf, data)
// Footer with generation info
g.writeFooter(pdf, data)
// Output to buffer
var buf bytes.Buffer
err := pdf.Output(&buf)
if err != nil {
return nil, fmt.Errorf("PDF output error: %w", err)
}
return buf.Bytes(), nil
}
// writeHeader writes the report header.
func (g *PDFGenerator) writeHeader(pdf *fpdf.Fpdf, data *ReportData) {
// Title
pdf.SetFont("Arial", "B", 18)
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(0, 12, "Pulse Metrics Report", "", 1, "C", false, 0, "")
// Subtitle with title
pdf.SetFont("Arial", "", 14)
pdf.SetTextColor(102, 102, 102)
pdf.CellFormat(0, 8, data.Title, "", 1, "C", false, 0, "")
pdf.Ln(5)
// Report details box
pdf.SetFillColor(245, 245, 245)
pdf.SetDrawColor(200, 200, 200)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(51, 51, 51)
boxX := 15.0
boxWidth := 180.0
boxHeight := 35.0
pdf.Rect(boxX, pdf.GetY(), boxWidth, boxHeight, "FD")
pdf.SetXY(boxX+5, pdf.GetY()+3)
// Resource info
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(30, 6, "Resource Type:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.CellFormat(50, 6, GetResourceTypeDisplayName(data.ResourceType), "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(30, 6, "Resource ID:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.CellFormat(0, 6, data.ResourceID, "", 1, "L", false, 0, "")
pdf.SetX(boxX + 5)
// Time period
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(30, 6, "Period:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 10)
periodStr := fmt.Sprintf("%s to %s", data.Start.Format("2006-01-02 15:04"), data.End.Format("2006-01-02 15:04"))
pdf.CellFormat(0, 6, periodStr, "", 1, "L", false, 0, "")
pdf.SetX(boxX + 5)
// Data points
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(30, 6, "Data Points:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.CellFormat(50, 6, fmt.Sprintf("%d", data.TotalPoints), "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(30, 6, "Generated:", "", 0, "L", false, 0, "")
pdf.SetFont("Arial", "", 10)
pdf.CellFormat(0, 6, data.GeneratedAt.Format("2006-01-02 15:04:05"), "", 1, "L", false, 0, "")
pdf.Ln(10)
}
// writeSummary writes the metrics summary table.
func (g *PDFGenerator) writeSummary(pdf *fpdf.Fpdf, data *ReportData) {
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(0, 10, "Summary", "", 1, "L", false, 0, "")
// Table header
pdf.SetFillColor(66, 139, 202)
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 10)
colWidths := []float64{40, 25, 25, 25, 25, 25, 15}
headers := []string{"Metric", "Count", "Min", "Max", "Avg", "Current", "Unit"}
for i, header := range headers {
pdf.CellFormat(colWidths[i], 8, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Table rows
pdf.SetFillColor(255, 255, 255)
pdf.SetTextColor(51, 51, 51)
pdf.SetFont("Arial", "", 9)
// 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)
fill := false
for _, metricType := range metricNames {
stats := data.Summary.ByMetric[metricType]
unit := GetMetricUnit(metricType)
if fill {
pdf.SetFillColor(245, 245, 245)
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.CellFormat(colWidths[0], 7, GetMetricTypeDisplayName(metricType), "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[1], 7, fmt.Sprintf("%d", stats.Count), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[2], 7, formatValue(stats.Min, unit), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[3], 7, formatValue(stats.Max, unit), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[4], 7, formatValue(stats.Avg, unit), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[5], 7, formatValue(stats.Current, unit), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[6], 7, unit, "1", 0, "C", fill, 0, "")
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(10)
}
// writeCharts writes simple line charts for each metric.
func (g *PDFGenerator) writeCharts(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Metrics) == 0 {
return
}
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(0, 10, "Charts", "", 1, "L", false, 0, "")
// Get sorted metric names
metricNames := make([]string, 0, len(data.Metrics))
for name := range data.Metrics {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
chartWidth := 180.0
chartHeight := 50.0
colors := [][]int{
{66, 139, 202}, // Blue
{92, 184, 92}, // Green
{240, 173, 78}, // Orange
{217, 83, 79}, // Red
{153, 102, 204}, // Purple
}
for i, metricType := range metricNames {
points := data.Metrics[metricType]
if len(points) < 2 {
continue
}
// Check if we need a new page
if pdf.GetY() > 220 {
pdf.AddPage()
}
// Chart title
pdf.SetFont("Arial", "B", 11)
pdf.SetTextColor(51, 51, 51)
unit := GetMetricUnit(metricType)
titleStr := GetMetricTypeDisplayName(metricType)
if unit != "" {
titleStr = fmt.Sprintf("%s (%s)", titleStr, unit)
}
pdf.CellFormat(0, 8, titleStr, "", 1, "L", false, 0, "")
// Draw chart background
chartX := 15.0
chartY := pdf.GetY()
pdf.SetFillColor(250, 250, 250)
pdf.SetDrawColor(200, 200, 200)
pdf.Rect(chartX, chartY, chartWidth, chartHeight, "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 min/max
valRange := maxVal - minVal
if valRange < 0.1 {
valRange = 10 // Minimum range for flat lines
}
minVal = math.Max(0, minVal-valRange*0.1)
maxVal = maxVal + valRange*0.1
// Draw Y-axis labels
pdf.SetFont("Arial", "", 7)
pdf.SetTextColor(128, 128, 128)
// Max label
pdf.SetXY(chartX-12, chartY)
pdf.CellFormat(10, 5, fmt.Sprintf("%.0f", maxVal), "", 0, "R", false, 0, "")
// Min label
pdf.SetXY(chartX-12, chartY+chartHeight-5)
pdf.CellFormat(10, 5, fmt.Sprintf("%.0f", minVal), "", 0, "R", false, 0, "")
// Draw line chart
color := colors[i%len(colors)]
pdf.SetDrawColor(color[0], color[1], color[2])
pdf.SetLineWidth(0.5)
startTime := points[0].Timestamp.Unix()
endTime := points[len(points)-1].Timestamp.Unix()
timeRange := float64(endTime - startTime)
if timeRange == 0 {
timeRange = 1
}
prevX, prevY := 0.0, 0.0
for j, p := range points {
// Calculate position
xPos := chartX + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(chartWidth-4)
yPos := chartY + chartHeight - 2 - ((p.Value-minVal)/(maxVal-minVal))*(chartHeight-4)
// Clamp Y position
if yPos < chartY+2 {
yPos = chartY + 2
}
if yPos > chartY+chartHeight-2 {
yPos = chartY + chartHeight - 2
}
if j > 0 {
pdf.Line(prevX, prevY, xPos, yPos)
}
prevX, prevY = xPos, yPos
}
// Draw X-axis labels (start and end time)
pdf.SetFont("Arial", "", 7)
pdf.SetTextColor(128, 128, 128)
pdf.SetXY(chartX, chartY+chartHeight+1)
pdf.CellFormat(40, 4, points[0].Timestamp.Format("01/02 15:04"), "", 0, "L", false, 0, "")
pdf.SetXY(chartX+chartWidth-40, chartY+chartHeight+1)
pdf.CellFormat(40, 4, points[len(points)-1].Timestamp.Format("01/02 15:04"), "", 0, "R", false, 0, "")
pdf.SetY(chartY + chartHeight + 10)
}
pdf.Ln(5)
}
// writeDataTable writes a detailed data table (limited rows).
func (g *PDFGenerator) writeDataTable(pdf *fpdf.Fpdf, data *ReportData) {
if len(data.Metrics) == 0 {
return
}
// Check if we need a new page
if pdf.GetY() > 200 {
pdf.AddPage()
}
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(0, 10, "Data Sample", "", 1, "L", false, 0, "")
pdf.SetFont("Arial", "", 8)
pdf.SetTextColor(128, 128, 128)
pdf.CellFormat(0, 5, "Showing first 50 data points. Export as CSV for complete data.", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Get sorted metric names
metricNames := make([]string, 0, len(data.Metrics))
for name := range data.Metrics {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
// Build columns
numCols := len(metricNames) + 1 // +1 for timestamp
colWidth := 180.0 / float64(numCols)
if colWidth < 25 {
colWidth = 25
}
// Table header
pdf.SetFillColor(66, 139, 202)
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
pdf.CellFormat(35, 6, "Timestamp", "1", 0, "C", true, 0, "")
for _, name := range metricNames {
displayName := GetMetricTypeDisplayName(name)
if len(displayName) > 12 {
displayName = displayName[:12]
}
pdf.CellFormat(colWidth, 6, displayName, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
// Collect all 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 to 50 rows
if len(timestamps) > 50 {
timestamps = timestamps[:50]
}
// Table rows
pdf.SetTextColor(51, 51, 51)
pdf.SetFont("Arial", "", 8)
fill := false
for _, ts := range timestamps {
// Check page break
if pdf.GetY() > 270 {
pdf.AddPage()
// Re-draw header
pdf.SetFillColor(66, 139, 202)
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Arial", "B", 8)
pdf.CellFormat(35, 6, "Timestamp", "1", 0, "C", true, 0, "")
for _, name := range metricNames {
displayName := GetMetricTypeDisplayName(name)
if len(displayName) > 12 {
displayName = displayName[:12]
}
pdf.CellFormat(colWidth, 6, displayName, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetTextColor(51, 51, 51)
pdf.SetFont("Arial", "", 8)
fill = false
}
if fill {
pdf.SetFillColor(245, 245, 245)
} else {
pdf.SetFillColor(255, 255, 255)
}
t := time.Unix(ts, 0)
pdf.CellFormat(35, 5, t.Format("01/02 15:04:05"), "1", 0, "L", fill, 0, "")
for _, metricName := range metricNames {
if val, ok := metricsByTime[metricName][ts]; ok {
pdf.CellFormat(colWidth, 5, fmt.Sprintf("%.2f", val), "1", 0, "C", fill, 0, "")
} else {
pdf.CellFormat(colWidth, 5, "-", "1", 0, "C", fill, 0, "")
}
}
pdf.Ln(-1)
fill = !fill
}
}
// writeFooter writes the report footer.
func (g *PDFGenerator) writeFooter(pdf *fpdf.Fpdf, data *ReportData) {
pdf.SetY(-20)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(128, 128, 128)
pdf.CellFormat(0, 5, fmt.Sprintf("Generated by Pulse - %s", data.GeneratedAt.Format(time.RFC3339)), "", 0, "C", false, 0, "")
}