Pulse/pkg/reporting/engine.go
2026-03-18 16:06:30 +00:00

495 lines
12 KiB
Go

package reporting
import (
"fmt"
"sort"
"strings"
"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
metricsStoreGetter func() *metrics.Store
csvGen *CSVGenerator
pdfGen *PDFGenerator
}
// EngineConfig holds configuration for the report engine.
type EngineConfig struct {
// MetricsStore is a direct reference to the metrics store.
// Use MetricsStoreGetter instead when the store may be replaced
// (e.g., after monitor reloads).
MetricsStore *metrics.Store
// MetricsStoreGetter dynamically resolves the current metrics store.
// When set, this is used instead of MetricsStore, ensuring queries
// always target the active store even after monitor reloads.
MetricsStoreGetter func() *metrics.Store
}
// NewReportEngine creates a new reporting engine.
func NewReportEngine(cfg EngineConfig) *ReportEngine {
return &ReportEngine{
metricsStore: cfg.MetricsStore,
metricsStoreGetter: cfg.MetricsStoreGetter,
csvGen: NewCSVGenerator(),
pdfGen: NewPDFGenerator(),
}
}
// getMetricsStore returns the current metrics store, preferring the dynamic
// getter over the static reference.
func (e *ReportEngine) getMetricsStore() *metrics.Store {
if e.metricsStoreGetter != nil {
return e.metricsStoreGetter()
}
return e.metricsStore
}
// CanonicalResourceType normalizes report resource type inputs to canonical v6 names.
// Returns an empty string when the type is unsupported.
func CanonicalResourceType(resourceType string) string {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "node":
return "node"
case "vm":
return "vm"
case "system-container":
return "system-container"
case "oci-container":
return "oci-container"
case "app-container":
return "app-container"
case "docker-host":
return "docker-host"
case "storage":
return "storage"
case "agent":
return "agent"
case "k8s":
return "k8s"
case "disk":
return "disk"
case "pbs":
return "pbs"
case "pmg":
return "pmg"
case "pod":
return "pod"
case "datastore":
return "datastore"
case "pool":
return "pool"
case "dataset":
return "dataset"
default:
return ""
}
}
func metricsStoreResourceTypes(canonicalType string) []string {
switch canonicalType {
case "system-container", "oci-container":
return []string{"container"}
case "app-container":
return []string{"dockerContainer", "docker"}
case "docker-host":
return []string{"dockerHost"}
case "":
return nil
default:
return []string{canonicalType}
}
}
func mergeMetricPointsByTimestamp(existing, incoming []metrics.MetricPoint) []metrics.MetricPoint {
if len(incoming) == 0 {
return existing
}
if len(existing) == 0 {
out := make([]metrics.MetricPoint, len(incoming))
copy(out, incoming)
sort.Slice(out, func(i, j int) bool {
return out[i].Timestamp.Before(out[j].Timestamp)
})
return out
}
seen := make(map[int64]struct{}, len(existing)+len(incoming))
out := make([]metrics.MetricPoint, len(existing))
copy(out, existing)
for _, point := range existing {
seen[point.Timestamp.Unix()] = struct{}{}
}
for _, point := range incoming {
key := point.Timestamp.Unix()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, point)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Timestamp.Before(out[j].Timestamp)
})
return out
}
func mergeMetricMapsByTimestamp(dst, src map[string][]metrics.MetricPoint) {
for metricType, incomingPoints := range src {
dst[metricType] = mergeMetricPointsByTimestamp(dst[metricType], incomingPoints)
}
}
// Generate creates a report in the specified format.
func (e *ReportEngine) Generate(req MetricReportRequest) (data []byte, contentType string, err error) {
if e.getMetricsStore() == 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", reportData.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
// Enrichment data (optional, for richer PDF reports)
Resource *ResourceInfo
Alerts []AlertInfo
Backups []BackupInfo
Storage []StorageInfo
Disks []DiskInfo
}
// 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) {
canonicalType := CanonicalResourceType(req.ResourceType)
if canonicalType == "" {
canonicalType = strings.TrimSpace(req.ResourceType)
}
data := &ReportData{
Title: req.Title,
ResourceType: canonicalType,
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", GetResourceTypeDisplayName(data.ResourceType), req.ResourceID)
}
// Copy enrichment data from request
data.Resource = req.Resource
data.Alerts = req.Alerts
data.Backups = req.Backups
data.Storage = req.Storage
data.Disks = req.Disks
store := e.getMetricsStore()
storeTypes := metricsStoreResourceTypes(canonicalType)
if len(storeTypes) == 0 {
storeTypes = []string{canonicalType}
}
var metricsMap map[string][]metrics.MetricPoint
if req.MetricType != "" {
// Query specific metric, merging across storage aliases during migrations.
var points []metrics.MetricPoint
for _, storeType := range storeTypes {
aliasPoints, queryErr := store.Query(storeType, req.ResourceID, req.MetricType, req.Start, req.End, 0)
if queryErr != nil {
return nil, queryErr
}
points = mergeMetricPointsByTimestamp(points, aliasPoints)
}
metricsMap = map[string][]metrics.MetricPoint{
req.MetricType: points,
}
} else {
// Query all metrics for the resource, merging across storage aliases.
metricsMap = make(map[string][]metrics.MetricPoint)
for _, storeType := range storeTypes {
aliasMap, queryErr := store.QueryAll(storeType, req.ResourceID, req.Start, req.End, 0)
if queryErr != nil {
return nil, queryErr
}
mergeMetricMapsByTimestamp(metricsMap, aliasMap)
}
}
if len(metricsMap) == 0 {
log.Warn().
Str("resourceType", data.ResourceType).
Str("resourceID", req.ResourceID).
Str("metricType", req.MetricType).
Time("start", req.Start).
Time("end", req.End).
Msg("Report query returned no metrics — verify resource ID matches stored metrics and time range contains data")
}
// 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
}
// GenerateMulti creates a multi-resource report in the specified format.
func (e *ReportEngine) GenerateMulti(req MultiReportRequest) (data []byte, contentType string, err error) {
if e.getMetricsStore() == nil {
return nil, "", fmt.Errorf("metrics store not initialized")
}
multiData := &MultiReportData{
Title: req.Title,
Start: req.Start,
End: req.End,
GeneratedAt: time.Now(),
}
if multiData.Title == "" {
multiData.Title = "Fleet Performance Report"
}
// Query metrics for each resource
var successCount int
for _, resReq := range req.Resources {
resReq.Start = req.Start
resReq.End = req.End
resReq.MetricType = req.MetricType
reportData, queryErr := e.queryMetrics(resReq)
if queryErr != nil {
log.Warn().
Str("resourceType", resReq.ResourceType).
Str("resourceID", resReq.ResourceID).
Err(queryErr).
Msg("Skipping resource in multi-report: failed to query metrics")
continue
}
multiData.Resources = append(multiData.Resources, reportData)
multiData.TotalPoints += reportData.TotalPoints
successCount++
}
if successCount == 0 {
return nil, "", fmt.Errorf("all resources failed to query metrics")
}
log.Debug().
Int("resources", successCount).
Int("skipped", len(req.Resources)-successCount).
Str("format", string(req.Format)).
Int("totalPoints", multiData.TotalPoints).
Msg("Generating multi-resource report")
switch req.Format {
case FormatCSV:
data, err = e.csvGen.GenerateMulti(multiData)
if err != nil {
return nil, "", fmt.Errorf("CSV generation failed: %w", err)
}
contentType = "text/csv"
case FormatPDF:
data, err = e.pdfGen.GenerateMulti(multiData)
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
}
// GetResourceTypeDisplayName returns a human-readable name for resource types.
func GetResourceTypeDisplayName(resourceType string) string {
switch CanonicalResourceType(resourceType) {
case "node":
return "Node"
case "vm":
return "Virtual Machine"
case "system-container":
return "System Container"
case "oci-container":
return "OCI Container"
case "app-container":
return "App Container"
case "docker-host":
return "Container Runtime"
case "storage":
return "Storage"
case "agent":
return "Agent"
case "k8s":
return "Kubernetes"
case "disk":
return "Disk"
case "pbs":
return "PBS"
case "pmg":
return "PMG"
case "pod":
return "Pod"
case "datastore":
return "Datastore"
case "pool":
return "Pool"
case "dataset":
return "Dataset"
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 ""
}
}