mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-24 15:18:28 +00:00
A design-partner review of real output called the PDFs out as thin, and
the page renders agreed: fleet reports spent one nearly blank A4 page
per resource, metric cards showed raw store keys with unformatted
values ('diskread 880000.00'), and value cells overlapped the adjacent
column for long readings.
- Fleet per-resource blocks now flow several to a page with separators,
breaking only when the next block will not fit (a 6-resource report
drops from 8 pages to 3); each block gains an availability line
- Rate metrics get display names and humane units (Disk Read 859.38
KiB/s instead of diskread 880000.00); byte units are self-describing
so the old +unit suffix no longer renders '12.00 GiBbytes'
- Metric card stat columns use bounded cells so long values cannot run
under the Avg/Samples labels
- Single reports merge Resource Details and Performance Summary onto a
shared page when they fit instead of stranding a third of a page of
content on each of two pages
Verified by regenerating branded fleet and single reports and reviewing
every rendered page.
402 lines
12 KiB
Go
402 lines
12 KiB
Go
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, fmt.Errorf("write CSV header section: %w", err)
|
|
}
|
|
|
|
// Write summary section
|
|
if err := g.writeSummary(w, data); err != nil {
|
|
return nil, fmt.Errorf("write CSV summary section: %w", err)
|
|
}
|
|
|
|
// Write data section
|
|
if err := g.writeData(w, data); err != nil {
|
|
return nil, fmt.Errorf("write CSV data section: %w", 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)},
|
|
}
|
|
if data.Availability.Observed() {
|
|
headers = append(headers,
|
|
[]string{"# Uptime:", fmt.Sprintf("%.2f%%", data.Availability.UptimePercent)},
|
|
[]string{"# Outages:", fmt.Sprintf("%d", data.Availability.DownIncidents)},
|
|
[]string{"# Total Downtime:", data.Availability.TotalDowntime.Round(time.Second).String()},
|
|
[]string{"# Observed:", fmt.Sprintf("%.1f%% of period", data.Availability.ObservedPercent)},
|
|
)
|
|
}
|
|
headers = append(headers, []string{""}) // Empty row as separator
|
|
|
|
for _, row := range headers {
|
|
if err := w.Write(row); err != nil {
|
|
return fmt.Errorf("write header row %q: %w", row[0], 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 fmt.Errorf("write summary section heading: %w", err)
|
|
}
|
|
|
|
// Column headers
|
|
if err := w.Write([]string{"Metric", "Count", "Min", "Max", "Average", "Current", "Unit"}); err != nil {
|
|
return fmt.Errorf("write summary column headers: %w", 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 fmt.Errorf("write summary row for metric %q: %w", metricType, err)
|
|
}
|
|
}
|
|
|
|
// Empty row as separator
|
|
if err := w.Write([]string{""}); err != nil {
|
|
return fmt.Errorf("write summary separator row: %w", 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 fmt.Errorf("write data section heading: %w", 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 fmt.Errorf("write data column headers: %w", 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 fmt.Errorf("write data row for timestamp %d: %w", ts, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateMulti creates a multi-resource CSV report from the provided data.
|
|
func (g *CSVGenerator) GenerateMulti(data *MultiReportData) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
w := csv.NewWriter(&buf)
|
|
|
|
// Write header comment rows
|
|
headers := [][]string{
|
|
{"# Pulse Multi-Resource Metrics Report"},
|
|
{"# Title:", data.Title},
|
|
{"# Resources:", fmt.Sprintf("%d", len(data.Resources))},
|
|
{"# 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)},
|
|
{""},
|
|
}
|
|
for _, row := range headers {
|
|
if err := w.Write(row); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource header row %q: %w", row[0], err)
|
|
}
|
|
}
|
|
|
|
// Write summary section
|
|
if err := w.Write([]string{"# SUMMARY"}); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource summary section heading: %w", err)
|
|
}
|
|
|
|
// Summary column headers
|
|
summaryHeaders := []string{"Resource Name", "Resource Type", "Resource ID", "Metric", "Count", "Min", "Max", "Average", "Current", "Unit"}
|
|
if err := w.Write(summaryHeaders); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource summary column headers: %w", err)
|
|
}
|
|
|
|
// Write summary rows for each resource
|
|
for _, rd := range data.Resources {
|
|
resourceName := rd.ResourceID
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
resourceName = rd.Resource.Name
|
|
}
|
|
resourceTypeDisplay := GetResourceTypeDisplayName(rd.ResourceType)
|
|
|
|
metricNames := make([]string, 0, len(rd.Summary.ByMetric))
|
|
for name := range rd.Summary.ByMetric {
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
sort.Strings(metricNames)
|
|
|
|
for _, metricType := range metricNames {
|
|
stats := rd.Summary.ByMetric[metricType]
|
|
unit := GetMetricUnit(metricType)
|
|
row := []string{
|
|
resourceName,
|
|
resourceTypeDisplay,
|
|
rd.ResourceID,
|
|
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 nil, fmt.Errorf("write multi-resource summary row for resource %q metric %q: %w", rd.ResourceID, metricType, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty separator
|
|
if err := w.Write([]string{""}); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource summary separator row: %w", err)
|
|
}
|
|
|
|
// Write data section
|
|
if err := w.Write([]string{"# DATA"}); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource data section heading: %w", err)
|
|
}
|
|
|
|
// Collect all unique metric names across all resources
|
|
metricNameSet := make(map[string]bool)
|
|
for _, rd := range data.Resources {
|
|
for name := range rd.Metrics {
|
|
metricNameSet[name] = true
|
|
}
|
|
}
|
|
metricNames := make([]string, 0, len(metricNameSet))
|
|
for name := range metricNameSet {
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
sort.Strings(metricNames)
|
|
|
|
// Build data header row
|
|
headerRow := []string{"Timestamp", "Resource Name", "Resource Type", "Resource ID"}
|
|
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 nil, fmt.Errorf("write multi-resource data column headers: %w", err)
|
|
}
|
|
|
|
// Collect all data points across all resources, with resource info
|
|
type dataRow struct {
|
|
timestamp int64
|
|
resourceName string
|
|
resourceType string
|
|
resourceID string
|
|
values map[string]float64
|
|
}
|
|
|
|
var allRows []dataRow
|
|
for _, rd := range data.Resources {
|
|
resourceName := rd.ResourceID
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
resourceName = rd.Resource.Name
|
|
}
|
|
resourceTypeDisplay := GetResourceTypeDisplayName(rd.ResourceType)
|
|
|
|
// Build a map of timestamp -> metric values for this resource
|
|
timestampValues := make(map[int64]map[string]float64)
|
|
for metricName, points := range rd.Metrics {
|
|
for _, p := range points {
|
|
ts := p.Timestamp.Unix()
|
|
if timestampValues[ts] == nil {
|
|
timestampValues[ts] = make(map[string]float64)
|
|
}
|
|
timestampValues[ts][metricName] = p.Value
|
|
}
|
|
}
|
|
|
|
for ts, values := range timestampValues {
|
|
allRows = append(allRows, dataRow{
|
|
timestamp: ts,
|
|
resourceName: resourceName,
|
|
resourceType: resourceTypeDisplay,
|
|
resourceID: rd.ResourceID,
|
|
values: values,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by timestamp, then by resource name
|
|
sort.Slice(allRows, func(i, j int) bool {
|
|
if allRows[i].timestamp != allRows[j].timestamp {
|
|
return allRows[i].timestamp < allRows[j].timestamp
|
|
}
|
|
return allRows[i].resourceName < allRows[j].resourceName
|
|
})
|
|
|
|
// Write data rows
|
|
for _, row := range allRows {
|
|
t := time.Unix(row.timestamp, 0)
|
|
csvRow := []string{t.Format(time.RFC3339), row.resourceName, row.resourceType, row.resourceID}
|
|
for _, metricName := range metricNames {
|
|
if val, ok := row.values[metricName]; ok {
|
|
csvRow = append(csvRow, fmt.Sprintf("%.2f", val))
|
|
} else {
|
|
csvRow = append(csvRow, "")
|
|
}
|
|
}
|
|
if err := w.Write(csvRow); err != nil {
|
|
return nil, fmt.Errorf("write multi-resource data row for resource %q at timestamp %d: %w", row.resourceID, row.timestamp, err)
|
|
}
|
|
}
|
|
|
|
w.Flush()
|
|
if err := w.Error(); err != nil {
|
|
return nil, fmt.Errorf("CSV write error: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// formatValue formats a metric value with appropriate precision.
|
|
func formatValue(value float64, unit string) string {
|
|
switch unit {
|
|
case "bytes":
|
|
return formatBytes(value)
|
|
case "bytes/s":
|
|
return formatBytes(value) + "/s"
|
|
}
|
|
return fmt.Sprintf("%.2f", value)
|
|
}
|
|
|
|
// formatMetricValue renders a value with its unit for display. Byte-based
|
|
// units are self-describing ("1.50 GiB", "880 KiB/s"); symbolic units are
|
|
// appended ("22.10%"); unitless values render bare.
|
|
func formatMetricValue(value float64, unit string) string {
|
|
switch unit {
|
|
case "bytes", "bytes/s", "":
|
|
return formatValue(value, unit)
|
|
default:
|
|
return formatValue(value, unit) + unit
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|