Files
rs_website/internal/uptime/uptime.go
2026-03-27 07:57:13 -05:00

143 lines
3.3 KiB
Go

// Package uptime stores hourly service status snapshots and computes uptime history.
package uptime
import (
"encoding/json"
"os"
"time"
)
// Snapshot records the status of all services at a point in time.
type Snapshot struct {
Time time.Time `json:"time"`
Statuses map[string]string `json:"statuses"` // service name → status
}
// DayBlock represents one day's aggregated status for display.
type DayBlock struct {
Date string // YYYY-MM-DD
Status string // worst status seen that day: up, degraded, down, or none
}
const maxDays = 30
// Record appends a snapshot to the history file, pruning entries older than 30 days.
// It is safe to call on every checker run; it deduplicates by hour.
func Record(path string, statuses map[string]string) error {
snapshots, _ := load(path)
now := time.Now().UTC()
currentHour := now.Truncate(time.Hour)
// Skip if we already have a snapshot for this hour.
if len(snapshots) > 0 {
last := snapshots[len(snapshots)-1]
if last.Time.Truncate(time.Hour).Equal(currentHour) {
return nil
}
}
snapshots = append(snapshots, Snapshot{
Time: currentHour,
Statuses: statuses,
})
// Prune entries older than 30 days.
cutoff := now.AddDate(0, 0, -maxDays)
kept := snapshots[:0]
for _, s := range snapshots {
if s.Time.After(cutoff) {
kept = append(kept, s)
}
}
return save(path, kept)
}
// ServiceHistory returns the last 30 daily blocks for a named service, oldest first.
func ServiceHistory(path string, serviceName string) []DayBlock {
snapshots, _ := load(path)
// Build a map of date → worst status.
dayStatus := make(map[string]string)
for _, s := range snapshots {
date := s.Time.UTC().Format("2006-01-02")
st, ok := s.Statuses[serviceName]
if !ok {
continue
}
existing := dayStatus[date]
dayStatus[date] = worst(existing, st)
}
// Build the last 30 days in order.
now := time.Now().UTC()
blocks := make([]DayBlock, maxDays)
for i := range blocks {
day := now.AddDate(0, 0, -(maxDays - 1 - i))
date := day.Format("2006-01-02")
status := dayStatus[date]
if status == "" {
status = "none"
}
blocks[i] = DayBlock{Date: date, Status: status}
}
return blocks
}
// UptimePct returns the percentage of hourly snapshots where the service was "up"
// over the last 30 days. Returns -1 if there is no data.
func UptimePct(path string, serviceName string) float64 {
snapshots, _ := load(path)
if len(snapshots) == 0 {
return -1
}
total, up := 0, 0
for _, s := range snapshots {
st, ok := s.Statuses[serviceName]
if !ok {
continue
}
total++
if st == "up" {
up++
}
}
if total == 0 {
return -1
}
return float64(up) / float64(total) * 100
}
// worst returns the more severe of two status strings.
func worst(a, b string) string {
rank := map[string]int{"up": 1, "degraded": 2, "down": 3}
if rank[b] > rank[a] {
return b
}
if a == "" {
return b
}
return a
}
func load(path string) ([]Snapshot, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var s []Snapshot
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
return s, nil
}
func save(path string, snapshots []Snapshot) error {
raw, err := json.MarshalIndent(snapshots, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, raw, 0644)
}