From 686c2e8716350eccc327236e00144f94142a57a5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Jun 2026 17:48:11 +0100 Subject: [PATCH] feat(reporting): availability section computed from the resource state timeline Performance reports answered 'what were the averages' but never 'was my infrastructure up' - the question a managed-service client reads a monthly report for. Reports now carry an Availability summary derived from the recorded resource change timeline (state_transition entries keyed by the canonical unified ID): - uptime percent over the observed portion of the window, outage count, total downtime, and longest outage, rendered in the executive summary with an explicit semantics note; fleet summaries gain a per-resource Uptime column and CSV exports gain availability header lines - absent/unknown spans are unobserved time: excluded from the uptime math entirely and disclosed as coverage, never counted as downtime. The journal records a registry absence for every monitor restart, so treating gaps as outages would invent fleet-wide downtime every time the operator restarts Pulse - warning states count as up (the resource is reachable and serving); the uptime label clamps rounding so any real downtime can never display as a clean 100% - resources with no timeline render no availability section at all rather than a fabricated number Verified live against a real 7-day window: uptime/outage/downtime figures reconcile with the raw resource_changes journal. --- internal/api/code_standards_test.go | 5 + internal/api/metrics_reporting_handlers.go | 1 + internal/api/reporting_availability.go | 164 ++++++++++++++++++ internal/api/reporting_availability_test.go | 175 ++++++++++++++++++++ internal/monitoring/monitor.go | 33 ++++ pkg/reporting/csv.go | 10 +- pkg/reporting/engine.go | 12 +- pkg/reporting/pdf.go | 116 ++++++++++++- pkg/reporting/pdf_ux_test.go | 107 ++++++++++++ pkg/reporting/reporting.go | 41 ++++- 10 files changed, 647 insertions(+), 17 deletions(-) create mode 100644 internal/api/reporting_availability.go create mode 100644 internal/api/reporting_availability_test.go diff --git a/internal/api/code_standards_test.go b/internal/api/code_standards_test.go index 864716bfa..fd500185c 100644 --- a/internal/api/code_standards_test.go +++ b/internal/api/code_standards_test.go @@ -218,6 +218,11 @@ func TestPaidDomainBoundaryAudit(t *testing.T) { knownPublicReportingFiles := map[string]bool{ "reporting_catalog_handlers.go": true, "reporting_inventory_handlers.go": true, + // Availability computation for the default report handlers. Since + // the enterprise reporting binder delegates generation to these + // defaults (the enterprise fork silently drifted and was removed), + // internal/api is the canonical home for the report pipeline. + "reporting_availability.go": true, } for name := range goFiles { diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index f73527e44..7818245ba 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -636,6 +636,7 @@ func sanitizeFilename(s string) string { // enrichReportRequest populates enrichment data from the monitor state func (h *ReportingHandlers) enrichReportRequest(ctx context.Context, orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) { h.resolveReportSubject(orgID, req, snapshot) + h.resolveReportAvailability(orgID, req, snapshot, start, end) switch req.ResourceType { case "node": h.enrichNodeReport(req, snapshot, start, end) diff --git a/internal/api/reporting_availability.go b/internal/api/reporting_availability.go new file mode 100644 index 000000000..26f999745 --- /dev/null +++ b/internal/api/reporting_availability.go @@ -0,0 +1,164 @@ +package api + +import ( + "sort" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" +) + +// reportAvailabilityChangeLimit bounds how many timeline rows a single +// report subject pulls. Production resources record a handful of state +// transitions per month; a resource that exceeds this is flapping hard +// enough that the truncated window still tells the honest story. +const reportAvailabilityChangeLimit = 20000 + +// availabilityState classifies a recorded resource state for uptime math. +type availabilityState int + +const ( + availabilityStateUnobserved availabilityState = iota + availabilityStateUp + availabilityStateDown +) + +// classifyAvailabilityState maps the canonical resource state vocabulary +// (online / warning / offline / unknown plus the synthetic "absent" emitted +// when a resource enters or leaves the registry) onto uptime semantics. +// Warning counts as up: the resource is reachable and serving, just +// unhealthy. Absent and unknown are unobserved - a monitoring gap is not an +// outage. Unrecognized future states default to up because a client-facing +// stability report claiming false downtime is worse than missing an exotic +// down state. +func classifyAvailabilityState(state string) availabilityState { + switch strings.ToLower(strings.TrimSpace(state)) { + case "", "absent", "unknown": + return availabilityStateUnobserved + case "offline": + return availabilityStateDown + default: + return availabilityStateUp + } +} + +func availabilityChangeTime(change unifiedresources.ResourceChange) time.Time { + if change.OccurredAt != nil && !change.OccurredAt.IsZero() { + return *change.OccurredAt + } + return change.ObservedAt +} + +// computeReportAvailability derives an availability summary for one report +// subject from its recorded state timeline. The walk reconstructs the state +// for every moment of [start, end]: the state before the first in-window +// transition is that transition's From; with no transitions at all the +// resource sat in currentState for the whole window (any change would have +// been journaled). +func computeReportAvailability(changes []unifiedresources.ResourceChange, currentState string, start, end time.Time) *reporting.AvailabilityInfo { + if !end.After(start) { + return nil + } + + transitions := make([]unifiedresources.ResourceChange, 0, len(changes)) + for _, change := range changes { + if change.Kind != unifiedresources.ChangeStateTransition { + continue + } + at := availabilityChangeTime(change) + if at.Before(start) || at.After(end) { + continue + } + transitions = append(transitions, change) + } + sort.SliceStable(transitions, func(i, j int) bool { + return availabilityChangeTime(transitions[i]).Before(availabilityChangeTime(transitions[j])) + }) + + initialState := currentState + if len(transitions) > 0 { + initialState = transitions[0].From + } + + var up, down time.Duration + var downIncidents int + var longestOutage, currentOutage time.Duration + + accumulate := func(state availabilityState, d time.Duration) { + if d <= 0 { + return + } + switch state { + case availabilityStateUp: + up += d + case availabilityStateDown: + down += d + currentOutage += d + } + if state != availabilityStateDown { + if currentOutage > longestOutage { + longestOutage = currentOutage + } + currentOutage = 0 + } + } + + cursor := start + state := classifyAvailabilityState(initialState) + for _, change := range transitions { + at := availabilityChangeTime(change) + accumulate(state, at.Sub(cursor)) + next := classifyAvailabilityState(change.To) + if next == availabilityStateDown && state != availabilityStateDown { + downIncidents++ + } + state = next + cursor = at + } + accumulate(state, end.Sub(cursor)) + if currentOutage > longestOutage { + longestOutage = currentOutage + } + + window := end.Sub(start) + observed := up + down + info := &reporting.AvailabilityInfo{ + ObservedPercent: 100 * float64(observed) / float64(window), + TotalDowntime: down, + LongestOutage: longestOutage, + DownIncidents: downIncidents, + } + if observed > 0 { + info.UptimePercent = 100 * float64(up) / float64(observed) + } + return info +} + +// resolveReportAvailability attaches the availability summary for the report +// subject. The change timeline is keyed by the canonical unified resource ID +// (not the metrics-target ID), so this runs against req.ResourceID. +func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) { + if h == nil || req == nil || h.mtMonitor == nil { + return + } + + currentState := "" + for i := range snapshot.Resources { + if snapshot.Resources[i].ID == req.ResourceID { + currentState = string(snapshot.Resources[i].Status) + break + } + } + if currentState == "" { + // Unknown to the unified registry: no timeline to report against. + return + } + + monitor, err := h.mtMonitor.GetMonitor(orgID) + if err != nil || monitor == nil { + return + } + changes := monitor.RecentResourceChanges(req.ResourceID, start, reportAvailabilityChangeLimit) + req.Availability = computeReportAvailability(changes, currentState, start, end) +} diff --git a/internal/api/reporting_availability_test.go b/internal/api/reporting_availability_test.go new file mode 100644 index 000000000..a35bcb33a --- /dev/null +++ b/internal/api/reporting_availability_test.go @@ -0,0 +1,175 @@ +package api + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func availabilityTransition(at time.Time, from, to string) unifiedresources.ResourceChange { + return unifiedresources.ResourceChange{ + Kind: unifiedresources.ChangeStateTransition, + ObservedAt: at, + From: from, + To: to, + } +} + +func TestComputeReportAvailability_NoTransitionsUsesCurrentState(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + + info := computeReportAvailability(nil, "online", start, end) + if info == nil { + t.Fatal("expected availability info") + } + if info.UptimePercent != 100 || info.ObservedPercent != 100 { + t.Fatalf("uptime=%v observed=%v, want 100/100", info.UptimePercent, info.ObservedPercent) + } + if info.DownIncidents != 0 || info.TotalDowntime != 0 { + t.Fatalf("expected no downtime, got %+v", info) + } +} + +func TestComputeReportAvailability_MidWindowOutage(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(100 * time.Hour) + changes := []unifiedresources.ResourceChange{ + // Store returns newest first; the computation must sort. + availabilityTransition(start.Add(60*time.Hour), "offline", "online"), + availabilityTransition(start.Add(50*time.Hour), "online", "offline"), + } + + info := computeReportAvailability(changes, "online", start, end) + if info == nil { + t.Fatal("expected availability info") + } + if info.UptimePercent != 90 { + t.Fatalf("uptime=%v, want 90 (10h down of 100h)", info.UptimePercent) + } + if info.ObservedPercent != 100 { + t.Fatalf("observed=%v, want 100", info.ObservedPercent) + } + if info.DownIncidents != 1 { + t.Fatalf("incidents=%d, want 1", info.DownIncidents) + } + if info.TotalDowntime != 10*time.Hour || info.LongestOutage != 10*time.Hour { + t.Fatalf("downtime=%v longest=%v, want 10h/10h", info.TotalDowntime, info.LongestOutage) + } +} + +// A monitoring gap (resource absent from the registry, e.g. the monitor +// itself restarted) is unobserved time: excluded from the uptime math and +// surfaced via ObservedPercent, never counted as an outage. +func TestComputeReportAvailability_AbsenceIsUnobservedNotDowntime(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(100 * time.Hour) + changes := []unifiedresources.ResourceChange{ + availabilityTransition(start.Add(20*time.Hour), "online", "absent"), + availabilityTransition(start.Add(40*time.Hour), "absent", "online"), + } + + info := computeReportAvailability(changes, "online", start, end) + if info == nil { + t.Fatal("expected availability info") + } + if info.UptimePercent != 100 { + t.Fatalf("uptime=%v, want 100 (gap is not downtime)", info.UptimePercent) + } + if info.ObservedPercent != 80 { + t.Fatalf("observed=%v, want 80 (20h gap of 100h)", info.ObservedPercent) + } + if info.DownIncidents != 0 { + t.Fatalf("incidents=%d, want 0", info.DownIncidents) + } +} + +func TestComputeReportAvailability_TwoOutagesTracksLongest(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(100 * time.Hour) + changes := []unifiedresources.ResourceChange{ + availabilityTransition(start.Add(10*time.Hour), "online", "offline"), + availabilityTransition(start.Add(12*time.Hour), "offline", "online"), + availabilityTransition(start.Add(50*time.Hour), "online", "offline"), + availabilityTransition(start.Add(56*time.Hour), "offline", "online"), + } + + info := computeReportAvailability(changes, "online", start, end) + if info.DownIncidents != 2 { + t.Fatalf("incidents=%d, want 2", info.DownIncidents) + } + if info.TotalDowntime != 8*time.Hour { + t.Fatalf("downtime=%v, want 8h", info.TotalDowntime) + } + if info.LongestOutage != 6*time.Hour { + t.Fatalf("longest=%v, want 6h", info.LongestOutage) + } + if info.UptimePercent != 92 { + t.Fatalf("uptime=%v, want 92", info.UptimePercent) + } +} + +func TestComputeReportAvailability_OfflineThroughWindowEnd(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(10 * time.Hour) + changes := []unifiedresources.ResourceChange{ + availabilityTransition(start.Add(8*time.Hour), "online", "offline"), + } + + info := computeReportAvailability(changes, "offline", start, end) + if info.UptimePercent != 80 { + t.Fatalf("uptime=%v, want 80", info.UptimePercent) + } + if info.TotalDowntime != 2*time.Hour || info.LongestOutage != 2*time.Hour { + t.Fatalf("downtime=%v longest=%v, want 2h/2h (outage still open at window end)", info.TotalDowntime, info.LongestOutage) + } + if info.DownIncidents != 1 { + t.Fatalf("incidents=%d, want 1", info.DownIncidents) + } +} + +func TestComputeReportAvailability_NeverObserved(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(10 * time.Hour) + + info := computeReportAvailability(nil, "unknown", start, end) + if info == nil { + t.Fatal("expected availability info") + } + if info.Observed() { + t.Fatalf("expected unobserved window, got %+v", info) + } + if info.UptimePercent != 0 { + t.Fatalf("uptime=%v, want 0 when never observed", info.UptimePercent) + } +} + +// Warning is up: the resource is reachable and serving. A stability report +// that counted warnings as outages would invent downtime. +func TestComputeReportAvailability_WarningCountsAsUp(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(10 * time.Hour) + changes := []unifiedresources.ResourceChange{ + availabilityTransition(start.Add(2*time.Hour), "online", "warning"), + availabilityTransition(start.Add(4*time.Hour), "warning", "online"), + } + + info := computeReportAvailability(changes, "online", start, end) + if info.UptimePercent != 100 || info.DownIncidents != 0 { + t.Fatalf("uptime=%v incidents=%d, want 100/0", info.UptimePercent, info.DownIncidents) + } +} + +func TestComputeReportAvailability_IgnoresNonTransitionChanges(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(10 * time.Hour) + changes := []unifiedresources.ResourceChange{ + {Kind: unifiedresources.ChangeAlertFired, ObservedAt: start.Add(time.Hour), From: "online", To: "offline"}, + } + + info := computeReportAvailability(changes, "online", start, end) + if info.UptimePercent != 100 || info.DownIncidents != 0 { + t.Fatalf("non-transition changes must not affect availability, got %+v", info) + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index d0b197c56..9d66dd58e 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -4126,6 +4126,39 @@ func (m *Monitor) MetricsTargetForResource(resourceID string) *unifiedresources. return resolver.MetricsTargetForResource(resourceID) } +// resourceChangeTimeline is the slice of the resource store needed to read +// the recorded state timeline for a canonical resource ID. +type resourceChangeTimeline interface { + GetRecentChanges(canonicalID string, since time.Time, limit int) ([]unifiedresources.ResourceChange, error) +} + +// RecentResourceChanges returns the recorded change timeline for a canonical +// unified resource ID since the given time, newest first (store order). +// Returns nil when no resource store is wired or it does not record changes. +func (m *Monitor) RecentResourceChanges(resourceID string, since time.Time, limit int) []unifiedresources.ResourceChange { + if m == nil { + return nil + } + + m.mu.RLock() + store := m.resourceStore + m.mu.RUnlock() + if store == nil { + return nil + } + + timeline, ok := store.(resourceChangeTimeline) + if !ok { + return nil + } + changes, err := timeline.GetRecentChanges(resourceID, since, limit) + if err != nil { + log.Warn().Err(err).Str("resourceID", resourceID).Msg("failed to read resource change timeline") + return nil + } + return changes +} + type monitorUnifiedStateView struct { resources []unifiedresources.Resource readState unifiedresources.ReadState diff --git a/pkg/reporting/csv.go b/pkg/reporting/csv.go index 162adcef5..f38cf5616 100644 --- a/pkg/reporting/csv.go +++ b/pkg/reporting/csv.go @@ -54,8 +54,16 @@ func (g *CSVGenerator) writeHeader(w *csv.Writer, data *ReportData) error { {"# 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 } + 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 { diff --git a/pkg/reporting/engine.go b/pkg/reporting/engine.go index a6990695d..488c05d71 100644 --- a/pkg/reporting/engine.go +++ b/pkg/reporting/engine.go @@ -216,11 +216,12 @@ type ReportData struct { Brand *ReportBrand // Enrichment data (optional, for richer PDF reports) - Resource *ResourceInfo - Alerts []AlertInfo - Backups []BackupInfo - Storage []StorageInfo - Disks []DiskInfo + Resource *ResourceInfo + Alerts []AlertInfo + Backups []BackupInfo + Storage []StorageInfo + Disks []DiskInfo + Availability *AvailabilityInfo // Interpretation layer (optional). When set, the renderer prefers these // over recomputing heuristic observations/recommendations inline. The @@ -284,6 +285,7 @@ func (e *ReportEngine) queryMetrics(req MetricReportRequest) (*ReportData, error data.Backups = req.Backups data.Storage = req.Storage data.Disks = req.Disks + data.Availability = req.Availability store := e.getMetricsStore() storeTypes := metricsStoreResourceTypes(canonicalType) diff --git a/pkg/reporting/pdf.go b/pkg/reporting/pdf.go index 7cd6e30fb..96c3399db 100644 --- a/pkg/reporting/pdf.go +++ b/pkg/reporting/pdf.go @@ -226,6 +226,91 @@ func (g *PDFGenerator) Generate(data *ReportData) ([]byte, error) { return buf.Bytes(), nil } +// formatOutageDuration renders a downtime duration for report copy, +// never collapsing a real outage to "0 minutes". +func formatOutageDuration(d time.Duration) string { + if d > 0 && d < time.Minute { + return "under a minute" + } + return formatDuration(d) +} + +// availabilityUptimeLabel renders an uptime percentage for report copy. +// Two decimal places keep "99.97%" distinguishable from "100%"; values +// that round up to a clean boundary are clamped so the label never +// overstates availability. +func availabilityUptimeLabel(percent float64) string { + if percent >= 100 { + return "100%" + } + label := fmt.Sprintf("%.2f%%", percent) + if label == "100.00%" { + return "99.99%" + } + return label +} + +// writeAvailabilitySection renders the observed-availability block in the +// executive summary. This is the number a managed-service client reads the +// report for: uptime over the period, outage count, and total downtime. +// Skipped entirely when no availability data was attached (no resource +// timeline available); renders a muted note when the resource was not +// observed during the window. +func (g *PDFGenerator) writeAvailabilitySection(pdf *fpdf.Fpdf, availability *AvailabilityInfo) { + if availability == nil { + return + } + + pdf.SetFont("Arial", "B", 11) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 8, "Availability", "", 1, "L", false, 0, "") + pdf.Ln(1) + + if !availability.Observed() { + pdf.SetFont("Arial", "", 10) + pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2]) + pdf.CellFormat(0, 6, "Not observed during this window - no state history was recorded for this resource.", "", 1, "L", false, 0, "") + pdf.Ln(4) + return + } + + uptimeColor := colorAccent + if availability.TotalDowntime > 0 { + uptimeColor = colorWarning + } + if availability.UptimePercent < 99 { + uptimeColor = colorDanger + } + + pdf.SetFont("Arial", "B", 20) + pdf.SetTextColor(uptimeColor[0], uptimeColor[1], uptimeColor[2]) + pdf.CellFormat(50, 10, availabilityUptimeLabel(availability.UptimePercent), "", 0, "L", false, 0, "") + + detail := "No outages observed" + if availability.DownIncidents > 0 { + outageWord := "outages" + if availability.DownIncidents == 1 { + outageWord = "outage" + } + detail = fmt.Sprintf("%d %s, %s total downtime (longest %s)", + availability.DownIncidents, outageWord, + formatOutageDuration(availability.TotalDowntime), + formatOutageDuration(availability.LongestOutage)) + } + pdf.SetFont("Arial", "", 10) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 10, detail, "", 1, "L", false, 0, "") + + // Disclose partial observation instead of letting a monitoring gap + // silently inflate (or deflate) the headline number. + if availability.ObservedPercent < 99.5 { + pdf.SetFont("Arial", "", 8) + pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2]) + pdf.CellFormat(0, 5, fmt.Sprintf("Based on the %.1f%% of this period Pulse was observing the resource; monitoring gaps are excluded, not counted as downtime.", availability.ObservedPercent), "", 1, "L", false, 0, "") + } + pdf.Ln(4) +} + // reportSubjectDisplayName returns the human-readable name for the report // subject, falling back to the raw resource ID when enrichment did not // resolve one. @@ -410,6 +495,8 @@ func (g *PDFGenerator) writeExecutiveSummary(pdf *fpdf.Fpdf, data *ReportData) { pdf.Ln(3) } + g.writeAvailabilitySection(pdf, data.Availability) + // Quick Stats - simple table format (avoids fpdf positioning bugs) pdf.SetFont("Arial", "B", 11) pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) @@ -1956,8 +2043,8 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) pdf.Ln(2) // Table header - colWidths := []float64{40, 25, 20, 23, 23, 23, 16} - headers := []string{"Resource", "Type", "Status", "Avg CPU", "Avg Mem", "Avg Disk", "Alerts"} + colWidths := []float64{37, 22, 16, 19, 19, 19, 19, 19} + headers := []string{"Resource", "Type", "Status", "Uptime", "Avg CPU", "Avg Mem", "Avg Disk", "Alerts"} pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2]) pdf.SetTextColor(255, 255, 255) @@ -2013,13 +2100,30 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) } pdf.CellFormat(colWidths[2], 6, status, "1", 0, "C", fill, 0, "") + // Uptime over the window. A dash means the resource was never + // observed (or no timeline was available) - distinct from 100%. + uptimeLabel := "-" + uptimeColor := colorTextMuted + if rd.Availability.Observed() { + uptimeLabel = availabilityUptimeLabel(rd.Availability.UptimePercent) + uptimeColor = colorAccent + if rd.Availability.TotalDowntime > 0 { + uptimeColor = colorWarning + } + if rd.Availability.UptimePercent < 99 { + uptimeColor = colorDanger + } + } + pdf.SetTextColor(uptimeColor[0], uptimeColor[1], uptimeColor[2]) + pdf.CellFormat(colWidths[3], 6, uptimeLabel, "1", 0, "C", fill, 0, "") + // Avg CPU var avgCPU float64 if stats, ok := rd.Summary.ByMetric["cpu"]; ok { avgCPU = stats.Avg } pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2]) - pdf.CellFormat(colWidths[3], 6, fmt.Sprintf("%.1f%%", avgCPU), "1", 0, "C", fill, 0, "") + pdf.CellFormat(colWidths[4], 6, fmt.Sprintf("%.1f%%", avgCPU), "1", 0, "C", fill, 0, "") if avgCPU > highestCPUVal { highestCPUVal = avgCPU @@ -2036,7 +2140,7 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) avgMem = stats.Avg } pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2]) - pdf.CellFormat(colWidths[4], 6, fmt.Sprintf("%.1f%%", avgMem), "1", 0, "C", fill, 0, "") + pdf.CellFormat(colWidths[5], 6, fmt.Sprintf("%.1f%%", avgMem), "1", 0, "C", fill, 0, "") // Avg Disk var avgDisk float64 @@ -2046,7 +2150,7 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) avgDisk = stats.Avg } pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2]) - pdf.CellFormat(colWidths[5], 6, fmt.Sprintf("%.1f%%", avgDisk), "1", 0, "C", fill, 0, "") + pdf.CellFormat(colWidths[6], 6, fmt.Sprintf("%.1f%%", avgDisk), "1", 0, "C", fill, 0, "") // Alerts count alertCount := 0 @@ -2056,7 +2160,7 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) } } pdf.SetTextColor(getAlertCountColor(alertCount)[0], getAlertCountColor(alertCount)[1], getAlertCountColor(alertCount)[2]) - pdf.CellFormat(colWidths[6], 6, fmt.Sprintf("%d", alertCount), "1", 0, "C", fill, 0, "") + pdf.CellFormat(colWidths[7], 6, fmt.Sprintf("%d", alertCount), "1", 0, "C", fill, 0, "") if alertCount > mostAlertsCount { mostAlertsCount = alertCount diff --git a/pkg/reporting/pdf_ux_test.go b/pkg/reporting/pdf_ux_test.go index b08a9a25f..c931e9542 100644 --- a/pkg/reporting/pdf_ux_test.go +++ b/pkg/reporting/pdf_ux_test.go @@ -287,3 +287,110 @@ func inflateStream(b []byte) ([]byte, error) { // pdf.go but tests referencing fpdf.New constants confirm we still // resolve the package. var _ = fpdf.New + +// TestExecutiveSummary_AvailabilitySectionRendersUptime asserts the +// availability block renders the headline uptime number, outage detail, +// and the partial-observation disclosure. This is the number an MSP's +// client reads the report for. +func TestExecutiveSummary_AvailabilitySectionRendersUptime(t *testing.T) { + data := &ReportData{ + Title: "Avail", + ResourceType: "vm", + ResourceID: "vm-1", + Start: time.Now().Add(-30 * 24 * time.Hour), + End: time.Now(), + GeneratedAt: time.Now(), + Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}}, + TotalPoints: 10, + Availability: &AvailabilityInfo{ + UptimePercent: 99.42, + ObservedPercent: 87.5, + TotalDowntime: 4 * time.Hour, + LongestOutage: 3 * time.Hour, + DownIncidents: 2, + }, + } + text := renderExecutiveSummaryText(t, data) + if !strings.Contains(text, "Availability") { + t.Errorf("expected Availability section, got:\n%s", text) + } + if !strings.Contains(text, "99.42%") { + t.Errorf("expected uptime percentage, got:\n%s", text) + } + if !strings.Contains(text, "2 outages") || !strings.Contains(text, "4 hours total downtime") { + t.Errorf("expected outage detail, got:\n%s", text) + } + if !strings.Contains(text, "87.5% of this period") { + t.Errorf("expected partial-observation disclosure, got:\n%s", text) + } +} + +// TestExecutiveSummary_AvailabilityOmittedWhenUnavailable asserts reports +// without a resource timeline render no availability section at all (no +// fabricated 100%). +func TestExecutiveSummary_AvailabilityOmittedWhenUnavailable(t *testing.T) { + data := &ReportData{ + Title: "NoAvail", + ResourceType: "vm", + ResourceID: "vm-1", + Start: time.Now().Add(-time.Hour), + End: time.Now(), + GeneratedAt: time.Now(), + Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}}, + TotalPoints: 10, + } + text := renderExecutiveSummaryText(t, data) + if strings.Contains(text, "Availability") { + t.Errorf("expected no availability section without data, got:\n%s", text) + } +} + +// TestFleetSummary_UptimeColumn asserts the fleet table carries the +// per-resource uptime column, with a dash for unobserved resources. +func TestFleetSummary_UptimeColumn(t *testing.T) { + now := time.Now() + multi := &MultiReportData{ + Title: "Fleet", + Start: now.Add(-30 * 24 * time.Hour), + End: now, + GeneratedAt: now, + Resources: []*ReportData{ + { + ResourceID: "vm-a", + ResourceType: "vm", + Resource: &ResourceInfo{Name: "alpha", Status: "online"}, + Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}}, + TotalPoints: 60, + Availability: &AvailabilityInfo{UptimePercent: 99.95, ObservedPercent: 100, TotalDowntime: 20 * time.Minute, DownIncidents: 1}, + }, + { + ResourceID: "vm-b", + ResourceType: "vm", + Resource: &ResourceInfo{Name: "beta", Status: "online"}, + Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}}, + TotalPoints: 60, + }, + }, + } + text := renderFleetSummaryText(t, multi) + if !strings.Contains(text, "Uptime") { + t.Errorf("expected Uptime column header, got:\n%s", text) + } + if !strings.Contains(text, "99.95%") { + t.Errorf("expected uptime value for observed resource, got:\n%s", text) + } +} + +// TestAvailabilityUptimeLabel_NeverOverstates pins the rounding clamp: a +// window with any downtime must not round up to a clean 100%. +func TestAvailabilityUptimeLabel_NeverOverstates(t *testing.T) { + if got := availabilityUptimeLabel(99.999); got != "99.99%" { + t.Fatalf("availabilityUptimeLabel(99.999) = %q, want 99.99%%", got) + } + if got := availabilityUptimeLabel(100); got != "100%" { + t.Fatalf("availabilityUptimeLabel(100) = %q, want 100%%", got) + } + if got := availabilityUptimeLabel(99.4249); got != "99.42%" { + t.Fatalf("availabilityUptimeLabel(99.4249) = %q, want 99.42%%", got) + } +} diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go index b899d4ef7..cc424e6bc 100644 --- a/pkg/reporting/reporting.go +++ b/pkg/reporting/reporting.go @@ -33,11 +33,12 @@ type MetricReportRequest struct { MetricsResourceID string // Optional enrichment data (populated by handler from monitor state) - Resource *ResourceInfo // Details about the resource being reported on - Alerts []AlertInfo // Active and recently resolved alerts for this resource - Backups []BackupInfo // Backup information for VMs/containers - Storage []StorageInfo // Storage pools (for nodes) - Disks []DiskInfo // Physical disk health (for nodes) + Resource *ResourceInfo // Details about the resource being reported on + Alerts []AlertInfo // Active and recently resolved alerts for this resource + Backups []BackupInfo // Backup information for VMs/containers + Storage []StorageInfo // Storage pools (for nodes) + Disks []DiskInfo // Physical disk health (for nodes) + Availability *AvailabilityInfo // Observed availability over the window (from the state timeline) // Optional narrative interpretation. When Narrator is non-nil the // engine builds a NarrativeInput from the queried report data and asks @@ -48,6 +49,36 @@ type MetricReportRequest struct { FindingsProvider FindingsProvider } +// AvailabilityInfo summarizes a resource's observed availability over the +// report window, derived from the recorded resource state timeline. +// +// Time the resource was absent from the registry or in an unknown state +// (for example while the monitor itself was restarting) is treated as +// unobserved: it is excluded from the uptime calculation entirely rather +// than counted as downtime, and disclosed through ObservedPercent. A +// monitoring gap is not an outage, and a client-facing stability report +// must not present one as such. +type AvailabilityInfo struct { + // UptimePercent is up / (up + down) over the observed portion of the + // window. Online and warning states count as up; offline counts as + // down. Zero when the resource was never observed in the window. + UptimePercent float64 + // ObservedPercent is the share of the report window during which the + // resource state was actually being recorded. + ObservedPercent float64 + // TotalDowntime is the cumulative time spent in a down state. + TotalDowntime time.Duration + // LongestOutage is the longest contiguous stretch of down time. + LongestOutage time.Duration + // DownIncidents counts distinct transitions into a down state. + DownIncidents int +} + +// Observed reports whether the resource was observed at all in the window. +func (a *AvailabilityInfo) Observed() bool { + return a != nil && a.ObservedPercent > 0 +} + // ResourceInfo contains details about the resource being reported on type ResourceInfo struct { Name string