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