diff --git a/pkg/reporting/csv.go b/pkg/reporting/csv.go new file mode 100644 index 000000000..7cabcd044 --- /dev/null +++ b/pkg/reporting/csv.go @@ -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]) +} diff --git a/pkg/reporting/engine.go b/pkg/reporting/engine.go new file mode 100644 index 000000000..081c03ab8 --- /dev/null +++ b/pkg/reporting/engine.go @@ -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 "" + } +} diff --git a/pkg/reporting/engine_test.go b/pkg/reporting/engine_test.go new file mode 100644 index 000000000..7052e3269 --- /dev/null +++ b/pkg/reporting/engine_test.go @@ -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, + } +} diff --git a/pkg/reporting/pdf.go b/pkg/reporting/pdf.go new file mode 100644 index 000000000..cc376aa48 --- /dev/null +++ b/pkg/reporting/pdf.go @@ -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, "") +}