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.
This commit is contained in:
rcourtman 2026-06-10 17:48:11 +01:00
parent 3dc06bea71
commit 686c2e8716
10 changed files with 647 additions and 17 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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