mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
3dc06bea71
commit
686c2e8716
10 changed files with 647 additions and 17 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
164
internal/api/reporting_availability.go
Normal file
164
internal/api/reporting_availability.go
Normal 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)
|
||||
}
|
||||
175
internal/api/reporting_availability_test.go
Normal file
175
internal/api/reporting_availability_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue