mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
The reporting engine held a direct pointer to the metrics store, which becomes invalid after a monitor reload (settings change, node config save, etc.) closes and recreates the store. Use a dynamic getter closure that always resolves to the current monitor's active store. Also adds diagnostic logging when report queries return zero metrics, and integration tests covering the full metrics-to-report pipeline including reload scenarios. Fixes #1186
361 lines
9.0 KiB
Go
361 lines
9.0 KiB
Go
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
|
|
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
|
|
}
|
|
|
|
// 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", 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
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
|
|
// 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()
|
|
|
|
var metricsMap map[string][]metrics.MetricPoint
|
|
var err error
|
|
|
|
if req.MetricType != "" {
|
|
// Query specific metric
|
|
points, queryErr := store.Query(req.ResourceType, req.ResourceID, req.MetricType, req.Start, req.End, 0)
|
|
if queryErr != nil {
|
|
return nil, queryErr
|
|
}
|
|
metricsMap = map[string][]metrics.MetricPoint{
|
|
req.MetricType: points,
|
|
}
|
|
} else {
|
|
// Query all metrics for the resource
|
|
metricsMap, err = store.QueryAll(req.ResourceType, req.ResourceID, req.Start, req.End, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if len(metricsMap) == 0 {
|
|
log.Warn().
|
|
Str("resourceType", req.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 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 ""
|
|
}
|
|
}
|