mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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.
175 lines
6.2 KiB
Go
175 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|