mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
495 lines
12 KiB
Go
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 ""
|
|
}
|
|
}
|