Files
uptop/internal/monitor/sla.go
T
lerko 9115ab720c
CI / test (pull_request) Successful in 1m55s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 56s
fix: six small fixes — rate limiter leak, DST SLA, probe sort, TUI cleanup
1. Rate limiter cleanup goroutine now stoppable via Stop() channel
   instead of looping forever. Prevents goroutine leak in tests.

2. Dead WindowSizeMsg branch in handleFormMsg removed — top-level
   Update handles resize before forms see it.

3. Probe results sorted by node ID — map iteration no longer
   reorders rows every render.

4. fmtAlertConfig takes models.AlertConfig directly instead of an
   anonymous struct the caller builds inline.

5. Backspace no longer aliases delete — d is the documented key.
   Prevents accidental delete-confirm on habitual backspace.

6. SLA daily buckets use time.Date day arithmetic instead of
   Add(-i*24h) — lands on midnight correctly across DST transitions.
2026-06-12 09:18:52 -04:00

218 lines
4.8 KiB
Go

package monitor
import (
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type SLAReport struct {
Window time.Duration
UptimePct float64
Downtime time.Duration
OutageCount int
LongestOut time.Duration
MTTR time.Duration
MTBF time.Duration
}
func ComputeSLA(changes []models.StateChange, currentStatus models.Status, window time.Duration) SLAReport {
now := time.Now()
windowStart := now.Add(-window)
report := SLAReport{Window: window}
if len(changes) == 0 {
if models.Status(currentStatus).IsBroken() {
report.UptimePct = 0
report.Downtime = window
} else {
report.UptimePct = 100
}
return report
}
// Sort changes chronologically (they come in DESC from DB).
sorted := make([]models.StateChange, len(changes))
copy(sorted, changes)
for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
// Determine status at window start: last transition before or at windowStart.
statusAtStart := string(models.StatusUp)
for i := len(sorted) - 1; i >= 0; i-- {
if !sorted[i].ChangedAt.After(windowStart) {
statusAtStart = sorted[i].ToStatus
break
}
}
var upTime, downTime time.Duration
var outages []time.Duration
cursor := windowStart
wasDown := models.Status(statusAtStart).IsBroken()
if wasDown {
report.OutageCount = 1
}
var outageStart time.Time
if wasDown {
outageStart = windowStart
}
for _, sc := range sorted {
if sc.ChangedAt.Before(windowStart) {
continue
}
if sc.ChangedAt.After(now) {
break
}
seg := sc.ChangedAt.Sub(cursor)
if wasDown {
downTime += seg
} else {
upTime += seg
}
newDown := models.Status(sc.ToStatus).IsBroken()
if !wasDown && newDown {
report.OutageCount++
outageStart = sc.ChangedAt
}
if wasDown && !newDown {
dur := sc.ChangedAt.Sub(outageStart)
outages = append(outages, dur)
}
wasDown = newDown
cursor = sc.ChangedAt
}
// Account for time from last change to now.
remaining := now.Sub(cursor)
if wasDown {
downTime += remaining
dur := now.Sub(outageStart)
outages = append(outages, dur)
} else {
upTime += remaining
}
total := upTime + downTime
if total > 0 {
report.UptimePct = float64(upTime) / float64(total) * 100
} else {
report.UptimePct = 100
}
report.Downtime = downTime
if len(outages) > 0 {
var totalOutage time.Duration
for _, d := range outages {
totalOutage += d
if d > report.LongestOut {
report.LongestOut = d
}
}
report.MTTR = totalOutage / time.Duration(len(outages))
}
if report.OutageCount > 0 && upTime > 0 {
report.MTBF = upTime / time.Duration(report.OutageCount)
}
return report
}
func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.Status, days int, now time.Time) []DayReport {
reports := make([]DayReport, days)
for i := 0; i < days; i++ {
dayStart := time.Date(now.Year(), now.Month(), now.Day()-i, 0, 0, 0, 0, now.Location())
dayEnd := time.Date(now.Year(), now.Month(), now.Day()-i+1, 0, 0, 0, 0, now.Location())
if i == 0 {
dayEnd = now
}
windowChanges := filterChangesForWindow(changes, dayStart, dayEnd)
statusAtStart := inferStatusAt(changes, dayStart)
sla := computeSLAForWindow(windowChanges, statusAtStart, dayStart, dayEnd)
reports[i] = DayReport{
Date: dayStart,
UptimePct: sla,
}
}
return reports
}
type DayReport struct {
Date time.Time
UptimePct float64
}
func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange {
var filtered []models.StateChange
for _, sc := range changes {
if !sc.ChangedAt.Before(start) && sc.ChangedAt.Before(end) {
filtered = append(filtered, sc)
}
}
return filtered
}
func inferStatusAt(changes []models.StateChange, at time.Time) string {
// Changes come DESC from DB. Walk backwards to find last change before `at`.
for _, sc := range changes {
if !sc.ChangedAt.After(at) {
return sc.ToStatus
}
}
return string(models.StatusUp)
}
func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 {
// Sort chronologically.
sorted := make([]models.StateChange, len(changes))
copy(sorted, changes)
for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
var upTime, downTime time.Duration
cursor := start
wasDown := models.Status(statusAtStart).IsBroken()
for _, sc := range sorted {
if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) {
continue
}
seg := sc.ChangedAt.Sub(cursor)
if wasDown {
downTime += seg
} else {
upTime += seg
}
wasDown = models.Status(sc.ToStatus).IsBroken()
cursor = sc.ChangedAt
}
remaining := end.Sub(cursor)
if wasDown {
downTime += remaining
} else {
upTime += remaining
}
total := upTime + downTime
if total <= 0 {
return 100
}
return float64(upTime) / float64(total) * 100
}