mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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])
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user