refactor: architecture foundations (status type, schema versioning, shared mock) #107

Merged
lerko merged 3 commits from refactor/arch-foundations into main 2026-06-11 20:20:24 +00:00
16 changed files with 152 additions and 137 deletions
Showing only changes of commit f00acbc280 - Show all commits
+1 -1
View File
@@ -157,7 +157,7 @@ loop:
results = append(results, probeResultItem{ results = append(results, probeResultItem{
SiteID: s.ID, SiteID: s.ID,
LatencyNs: cr.LatencyNs, LatencyNs: cr.LatencyNs,
IsUp: cr.Status == "UP", IsUp: cr.Status == string(models.StatusUp),
ErrorReason: cr.ErrorReason, ErrorReason: cr.ErrorReason,
}) })
mu.Unlock() mu.Unlock()
+4 -3
View File
@@ -2,11 +2,12 @@ package metrics
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
) )
func Handler(eng *monitor.Engine) http.HandlerFunc { func Handler(eng *monitor.Engine) http.HandlerFunc {
@@ -19,7 +20,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == "UP" { if s.Status == models.StatusUp {
val = 1 val = 1
} }
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
+1 -1
View File
@@ -28,7 +28,7 @@ type Site struct {
Regions string Regions string
FailureCount int FailureCount int
Status string Status Status
StatusCode int StatusCode int
Latency time.Duration Latency time.Duration
CertExpiry time.Time CertExpiry time.Time
+18
View File
@@ -0,0 +1,18 @@
package models
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
StatusPending Status = "PENDING"
StatusLate Status = "LATE"
StatusStale Status = "STALE"
StatusSSLExp Status = "SSL EXP"
)
func (s Status) IsBroken() bool {
return s == StatusDown || s == StatusSSLExp
}
func (s Status) String() string { return string(s) }
+16 -16
View File
@@ -47,7 +47,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie
if ips, err := net.LookupIP(host); err == nil { if ips, err := net.LookupIP(host); err == nil {
for _, ip := range ips { for _, ip := range ips {
if isPrivateIP(ip) { if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "target resolves to private IP"}
} }
} }
} }
@@ -64,7 +64,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie
case "dns": case "dns":
return runDNSCheck(ctx, site) return runDNSCheck(ctx, site)
default: default:
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "unsupported monitor type: " + site.Type}
} }
} }
@@ -80,7 +80,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "invalid request: " + err.Error()}
} }
client := strict client := strict
@@ -94,12 +94,12 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
result := CheckResult{ result := CheckResult{
SiteID: site.ID, SiteID: site.ID,
Status: "UP", Status: string(models.StatusUp),
LatencyNs: latency.Nanoseconds(), LatencyNs: latency.Nanoseconds(),
} }
if err != nil { if err != nil {
result.Status = "DOWN" result.Status = string(models.StatusDown)
result.ErrorReason = truncateError(err.Error(), maxErrorLength) result.ErrorReason = truncateError(err.Error(), maxErrorLength)
return result return result
} }
@@ -107,7 +107,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
result.StatusCode = resp.StatusCode result.StatusCode = resp.StatusCode
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
result.Status = "DOWN" result.Status = string(models.StatusDown)
expected := site.AcceptedCodes expected := site.AcceptedCodes
if expected == "" { if expected == "" {
expected = defaultAcceptedCodes expected = defaultAcceptedCodes
@@ -120,7 +120,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
cert := resp.TLS.PeerCertificates[0] cert := resp.TLS.PeerCertificates[0]
result.CertExpiry = cert.NotAfter result.CertExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) { if time.Now().After(cert.NotAfter) {
result.Status = "SSL EXP" result.Status = string(models.StatusSSLExp)
result.ErrorReason = "SSL certificate expired" result.ErrorReason = "SSL certificate expired"
} }
} }
@@ -136,7 +136,7 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult {
pinger, err := probing.NewPinger(host) pinger, err := probing.NewPinger(host)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "ping setup: " + err.Error()}
} }
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = siteTimeout(site) pinger.Timeout = siteTimeout(site)
@@ -147,14 +147,14 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
} }
if pinger.Statistics().PacketsRecv == 0 { if pinger.Statistics().PacketsRecv == 0 {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
} }
stats := pinger.Statistics() stats := pinger.Statistics()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: stats.AvgRtt.Nanoseconds()}
} }
func runPortCheck(_ context.Context, site models.Site) CheckResult { func runPortCheck(_ context.Context, site models.Site) CheckResult {
@@ -170,10 +170,10 @@ func runPortCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)}
} }
_ = conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
} }
func runDNSCheck(_ context.Context, site models.Site) CheckResult { func runDNSCheck(_ context.Context, site models.Site) CheckResult {
@@ -221,12 +221,12 @@ func runDNSCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
} }
if r.Rcode != dns.RcodeSuccess { if r.Rcode != dns.RcodeSuccess {
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]}
} }
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
} }
func siteTimeout(site models.Site) time.Duration { func siteTimeout(site models.Site) time.Duration {
+51 -52
View File
@@ -334,7 +334,7 @@ func (e *Engine) RecordHeartbeat(token string) bool {
} }
var ( var (
prevStatus string prevStatus models.Status
name string name string
alertID int alertID int
downSince time.Time downSince time.Time
@@ -346,12 +346,12 @@ func (e *Engine) RecordHeartbeat(token string) bool {
downSince = s.StatusChangedAt // captured before mutation = when it went down downSince = s.StatusChangedAt // captured before mutation = when it went down
s.LastCheck = time.Now() s.LastCheck = time.Now()
s.Status = "UP" s.Status = models.StatusUp
s.FailureCount = 0 s.FailureCount = 0
s.Latency = 0 s.Latency = 0
s.LastError = "" s.LastError = ""
s.LastSuccessAt = time.Now() s.LastSuccessAt = time.Now()
if prevStatus != "UP" { if prevStatus != models.StatusUp {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} }
}) })
@@ -360,13 +360,13 @@ func (e *Engine) RecordHeartbeat(token string) bool {
} }
switch prevStatus { switch prevStatus {
case "PENDING": case models.StatusPending:
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
case "LATE": case models.StatusLate:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
case "STALE": case models.StatusStale:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
case "DOWN": case models.StatusDown:
downDur := "" downDur := ""
if !downSince.IsZero() { if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince))) downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
@@ -375,8 +375,8 @@ func (e *Engine) RecordHeartbeat(token string) bool {
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur)) go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
} }
if prevStatus != "UP" && prevStatus != "PENDING" { if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: prevStatus, toStatus: "UP"}) e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
} }
return true return true
@@ -434,12 +434,12 @@ func (e *Engine) Start(ctx context.Context) {
e.mu.RUnlock() e.mu.RUnlock()
if !exists { if !exists {
e.mu.Lock() e.mu.Lock()
s.Status = "PENDING" s.Status = models.StatusPending
if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
if h.Statuses[len(h.Statuses)-1] { if h.Statuses[len(h.Statuses)-1] {
s.Status = "UP" s.Status = models.StatusUp
} else { } else {
s.Status = "DOWN" s.Status = models.StatusDown
} }
if len(h.Latencies) > 0 { if len(h.Latencies) > 0 {
s.Latency = h.Latencies[len(h.Latencies)-1] s.Latency = h.Latencies[len(h.Latencies)-1]
@@ -686,7 +686,7 @@ func (e *Engine) checkByID(ctx context.Context, id int) {
} }
func (e *Engine) checkPush(_ context.Context, site models.Site) { func (e *Engine) checkPush(_ context.Context, site models.Site) {
if site.Status == "PENDING" { if site.Status == models.StatusPending {
return return
} }
@@ -702,16 +702,16 @@ func (e *Engine) checkPush(_ context.Context, site models.Site) {
now := time.Now() now := time.Now()
if now.After(graceEnd) { if now.After(graceEnd) {
if site.Status != "DOWN" { if site.Status != models.StatusDown {
e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed") e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
} }
} else if now.After(staleMark) { } else if now.After(staleMark) {
if site.Status != "STALE" { if site.Status != models.StatusStale {
e.handleStatusChange(site, "STALE", 0, 0, "heartbeat stale") e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
} }
} else if now.After(overdue) { } else if now.After(overdue) {
if site.Status != "LATE" { if site.Status != models.StatusLate {
e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue") e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
} }
} }
} }
@@ -727,9 +727,10 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
inMaint := e.isInMaintenance(snap.ID) inMaint := e.isInMaintenance(snap.ID)
status := models.Status(rawStatus)
var ( var (
prev, next string prev, next models.Status
name, typ string name, typ string
alertID int alertID int
failCount, maxRetries int failCount, maxRetries int
@@ -745,7 +746,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
_, exists := e.applyState(snap.ID, func(s *models.Site) { _, exists := e.applyState(snap.ID, func(s *models.Site) {
// A non-UP result computed from a stale snapshot must not override a // A non-UP result computed from a stale snapshot must not override a
// heartbeat (or newer check) that landed while we were evaluating. // heartbeat (or newer check) that landed while we were evaluating.
if rawStatus != "UP" && s.LastCheck.After(snap.LastCheck) { if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
skipped = true skipped = true
return return
} }
@@ -764,24 +765,24 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
s.HasSSL = snap.HasSSL s.HasSSL = snap.HasSSL
s.CertExpiry = snap.CertExpiry s.CertExpiry = snap.CertExpiry
s.LastError = errorReason s.LastError = errorReason
if rawStatus == "UP" { if status == models.StatusUp {
s.LastSuccessAt = time.Now() s.LastSuccessAt = time.Now()
s.LastError = "" s.LastError = ""
} }
// Status + failure-count transition, based on the CURRENT live status. // Status + failure-count transition, based on the CURRENT live status.
if rawStatus == "UP" { if status == models.StatusUp {
s.FailureCount = 0 s.FailureCount = 0
s.Status = "UP" s.Status = models.StatusUp
} else { } else {
if s.FailureCount <= s.MaxRetries { if s.FailureCount <= s.MaxRetries {
s.FailureCount++ s.FailureCount++
} }
if s.FailureCount > s.MaxRetries { if s.FailureCount > s.MaxRetries {
if s.Status != rawStatus { if s.Status != status {
confirmedDown = true confirmedDown = true
} }
s.Status = rawStatus s.Status = status
s.FailureCount = s.MaxRetries + 1 s.FailureCount = s.MaxRetries + 1
} else { } else {
failedCheck = true failedCheck = true
@@ -789,16 +790,16 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
failCount = s.FailureCount failCount = s.FailureCount
if s.Status != prev && prev != "PENDING" { if s.Status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} else if s.StatusChangedAt.IsZero() && s.Status != "PENDING" { } else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} }
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold). // SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
if typ == "http" && s.CheckSSL && s.HasSSL { if typ == "http" && s.CheckSSL && s.HasSSL {
days := int(time.Until(s.CertExpiry).Hours() / 24) days := int(time.Until(s.CertExpiry).Hours() / 24)
if days <= s.ExpiryThreshold && !s.SentSSLWarning && rawStatus != "SSL EXP" { if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
sslWarnFire = true sslWarnFire = true
sslDays = days sslDays = days
s.SentSSLWarning = true s.SentSSLWarning = true
@@ -815,7 +816,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
return return
} }
e.recordCheck(snap.ID, latency, rawStatus == "UP") e.recordCheck(snap.ID, latency, status == models.StatusUp)
if confirmedDown { if confirmedDown {
if errorReason != "" { if errorReason != "" {
@@ -827,8 +828,8 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries)) e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
} }
if changed && prev != "PENDING" { if changed && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: prev, toStatus: next, reason: errorReason}) e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
} }
if sslWarnFire { if sslWarnFire {
@@ -839,13 +840,11 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
} }
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } if prev == models.StatusUp && next == models.StatusLate {
if prev == "UP" && next == "LATE" {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name)) e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
} }
if !isBroken(prev) && isBroken(next) && next != "PENDING" { if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
if inMaint { if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name)) e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
} else { } else {
@@ -859,7 +858,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.triggerAlert(alertID, "🚨 ALERT", msg) e.triggerAlert(alertID, "🚨 ALERT", msg)
} }
} }
if isBroken(prev) && next == "UP" { if prev.IsBroken() && next == models.StatusUp {
downDur := "" downDur := ""
if !downSince.IsZero() { if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince))) downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
@@ -869,7 +868,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur)) e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
} }
} }
if prev == "LATE" && next == "UP" && !isBroken(prev) { if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name)) e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
} }
} }
@@ -991,12 +990,12 @@ func (e *Engine) GetDisplayStatus(site models.Site) string {
if e.isInMaintenance(site.ID) { if e.isInMaintenance(site.ID) {
return "MAINT" return "MAINT"
} }
return site.Status return string(site.Status)
} }
func (e *Engine) checkGroup(_ context.Context, site models.Site) { func (e *Engine) checkGroup(_ context.Context, site models.Site) {
e.mu.RLock() e.mu.RLock()
status := "UP" status := models.StatusUp
hasChildren := false hasChildren := false
for _, child := range e.liveState { for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" { if child.ParentID != site.ID || child.Type == "group" {
@@ -1006,20 +1005,20 @@ func (e *Engine) checkGroup(_ context.Context, site models.Site) {
if child.Paused || e.isInMaintenance(child.ID) { if child.Paused || e.isInMaintenance(child.ID) {
continue continue
} }
if child.Status == "DOWN" || child.Status == "SSL EXP" { if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
status = "DOWN" status = models.StatusDown
} else if child.Status == "STALE" && status != "DOWN" { } else if child.Status == models.StatusStale && status != models.StatusDown {
status = "STALE" status = models.StatusStale
} else if child.Status == "LATE" && status != "DOWN" && status != "STALE" { } else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
status = "LATE" status = models.StatusLate
} else if child.Status == "PENDING" && status != "DOWN" && status != "STALE" && status != "LATE" { } else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
status = "PENDING" status = models.StatusPending
} }
} }
e.mu.RUnlock() e.mu.RUnlock()
if !hasChildren { if !hasChildren {
status = "PENDING" status = models.StatusPending
} }
e.applyState(site.ID, func(s *models.Site) { e.applyState(site.ID, func(s *models.Site) {
@@ -1072,15 +1071,15 @@ func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, i
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy) aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
rawStatus := "UP" probeStatus := models.StatusUp
if !aggUp { if !aggUp {
rawStatus = "DOWN" probeStatus = models.StatusDown
} }
updatedSite := site updatedSite := site
updatedSite.Latency = time.Duration(avgLatency) updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now() updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency), errorReason) e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
} }
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult { func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
+9 -13
View File
@@ -16,14 +16,14 @@ type SLAReport struct {
MTBF time.Duration MTBF time.Duration
} }
func ComputeSLA(changes []models.StateChange, currentStatus string, window time.Duration) SLAReport { func ComputeSLA(changes []models.StateChange, currentStatus models.Status, window time.Duration) SLAReport {
now := time.Now() now := time.Now()
windowStart := now.Add(-window) windowStart := now.Add(-window)
report := SLAReport{Window: window} report := SLAReport{Window: window}
if len(changes) == 0 { if len(changes) == 0 {
if isDown(currentStatus) { if models.Status(currentStatus).IsBroken() {
report.UptimePct = 0 report.UptimePct = 0
report.Downtime = window report.Downtime = window
} else { } else {
@@ -40,7 +40,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
} }
// Determine status at window start: last transition before or at windowStart. // Determine status at window start: last transition before or at windowStart.
statusAtStart := "UP" statusAtStart := string(models.StatusUp)
for i := len(sorted) - 1; i >= 0; i-- { for i := len(sorted) - 1; i >= 0; i-- {
if !sorted[i].ChangedAt.After(windowStart) { if !sorted[i].ChangedAt.After(windowStart) {
statusAtStart = sorted[i].ToStatus statusAtStart = sorted[i].ToStatus
@@ -51,7 +51,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
var upTime, downTime time.Duration var upTime, downTime time.Duration
var outages []time.Duration var outages []time.Duration
cursor := windowStart cursor := windowStart
wasDown := isDown(statusAtStart) wasDown := models.Status(statusAtStart).IsBroken()
if wasDown { if wasDown {
report.OutageCount = 1 report.OutageCount = 1
@@ -77,7 +77,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
upTime += seg upTime += seg
} }
newDown := isDown(sc.ToStatus) newDown := models.Status(sc.ToStatus).IsBroken()
if !wasDown && newDown { if !wasDown && newDown {
report.OutageCount++ report.OutageCount++
outageStart = sc.ChangedAt outageStart = sc.ChangedAt
@@ -127,7 +127,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
return report return report
} }
func ComputeDailyBreakdown(changes []models.StateChange, currentStatus string, days int, now time.Time) []DayReport { func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.Status, days int, now time.Time) []DayReport {
reports := make([]DayReport, days) reports := make([]DayReport, days)
for i := 0; i < days; i++ { for i := 0; i < days; i++ {
@@ -159,10 +159,6 @@ type DayReport struct {
UptimePct float64 UptimePct float64
} }
func isDown(status string) bool {
return status == "DOWN" || status == "SSL EXP"
}
func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange { func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange {
var filtered []models.StateChange var filtered []models.StateChange
for _, sc := range changes { for _, sc := range changes {
@@ -180,7 +176,7 @@ func inferStatusAt(changes []models.StateChange, at time.Time) string {
return sc.ToStatus return sc.ToStatus
} }
} }
return "UP" return string(models.StatusUp)
} }
func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 { func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 {
@@ -193,7 +189,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
var upTime, downTime time.Duration var upTime, downTime time.Duration
cursor := start cursor := start
wasDown := isDown(statusAtStart) wasDown := models.Status(statusAtStart).IsBroken()
for _, sc := range sorted { for _, sc := range sorted {
if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) { if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) {
@@ -205,7 +201,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
} else { } else {
upTime += seg upTime += seg
} }
wasDown = isDown(sc.ToStatus) wasDown = models.Status(sc.ToStatus).IsBroken()
cursor = sc.ChangedAt cursor = sc.ChangedAt
} }
+13 -13
View File
@@ -137,24 +137,24 @@ func TestComputeDailyBreakdown(t *testing.T) {
} }
} }
func TestIsDown(t *testing.T) { func TestIsBroken(t *testing.T) {
if !isDown("DOWN") { if !models.StatusDown.IsBroken() {
t.Error("DOWN should be down") t.Error("DOWN should be broken")
} }
if !isDown("SSL EXP") { if !models.StatusSSLExp.IsBroken() {
t.Error("SSL EXP should be down") t.Error("SSL EXP should be broken")
} }
if isDown("UP") { if models.StatusUp.IsBroken() {
t.Error("UP should not be down") t.Error("UP should not be broken")
} }
if isDown("LATE") { if models.StatusLate.IsBroken() {
t.Error("LATE should not be down") t.Error("LATE should not be broken")
} }
if isDown("STALE") { if models.StatusStale.IsBroken() {
t.Error("STALE should not be down") t.Error("STALE should not be broken")
} }
if isDown("PENDING") { if models.StatusPending.IsBroken() {
t.Error("PENDING should not be down") t.Error("PENDING should not be broken")
} }
} }
+5 -5
View File
@@ -468,15 +468,15 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} }
public := make(map[int]statusSite, len(state)) public := make(map[int]statusSite, len(state))
for id, site := range state { for id, site := range state {
status := site.Status displayStatus := string(site.Status)
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
status = "MAINT" displayStatus = "MAINT"
} }
public[id] = statusSite{ public[id] = statusSite{
Name: site.Name, Name: site.Name,
Type: site.Type, Type: site.Type,
URL: site.URL, URL: site.URL,
Status: status, Status: displayStatus,
Paused: site.Paused, Paused: site.Paused,
LastCheck: site.LastCheck, LastCheck: site.LastCheck,
Latency: site.Latency, Latency: site.Latency,
@@ -569,10 +569,10 @@ func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine)
sort.Slice(sites, func(i, j int) bool { sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status { if sites[i].Status != sites[j].Status {
if sites[i].Status == "DOWN" { if sites[i].Status == models.StatusDown {
return true return true
} }
if sites[j].Status == "DOWN" { if sites[j].Status == models.StatusDown {
return false return false
} }
} }
+9 -9
View File
@@ -143,16 +143,16 @@ func (m Model) fmtRetries(site models.Site) string {
dispCount = site.MaxRetries dispCount = site.MaxRetries
} }
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
if site.Status == "DOWN" { if site.Status == models.StatusDown {
return m.st.dangerStyle.Render(s) return m.st.dangerStyle.Render(s)
} }
if site.Status == "UP" && site.FailureCount > 0 { if site.Status == models.StatusUp && site.FailureCount > 0 {
return m.st.warnStyle.Render(s) return m.st.warnStyle.Render(s)
} }
return s return s
} }
func (m Model) fmtStatus(status string, paused bool, inMaint bool) string { func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused { if paused {
return m.st.warnStyle.Render("◇ PAUSED") return m.st.warnStyle.Render("◇ PAUSED")
} }
@@ -160,18 +160,18 @@ func (m Model) fmtStatus(status string, paused bool, inMaint bool) string {
return m.st.maintStyle.Render("◼ MAINT") return m.st.maintStyle.Render("◼ MAINT")
} }
switch status { switch status {
case "DOWN": case models.StatusDown:
return m.st.dangerStyle.Render("▼ DOWN") return m.st.dangerStyle.Render("▼ DOWN")
case "SSL EXP": case models.StatusSSLExp:
return m.st.dangerStyle.Render("▼ SSL EXP") return m.st.dangerStyle.Render("▼ SSL EXP")
case "LATE": case models.StatusLate:
return m.st.warnStyle.Render("◆ LATE") return m.st.warnStyle.Render("◆ LATE")
case "STALE": case models.StatusStale:
return m.st.staleStyle.Render("◆ STALE") return m.st.staleStyle.Render("◆ STALE")
case "PENDING": case models.StatusPending:
return m.st.subtleStyle.Render("○ PENDING") return m.st.subtleStyle.Render("○ PENDING")
default: default:
return m.st.specialStyle.Render("▲ " + status) return m.st.specialStyle.Render("▲ " + string(status))
} }
} }
+9 -9
View File
@@ -56,19 +56,19 @@ func TestSiteOrder(t *testing.T) {
func TestFmtStatus(t *testing.T) { func TestFmtStatus(t *testing.T) {
tests := []struct { tests := []struct {
status string status models.Status
paused bool paused bool
inMaint bool inMaint bool
wantSub string wantSub string
}{ }{
{"DOWN", false, false, "▼ DOWN"}, {models.StatusDown, false, false, "▼ DOWN"},
{"UP", false, false, "▲ UP"}, {models.StatusUp, false, false, "▲ UP"},
{"SSL EXP", false, false, "▼ SSL EXP"}, {models.StatusSSLExp, false, false, "▼ SSL EXP"},
{"LATE", false, false, "◆ LATE"}, {models.StatusLate, false, false, "◆ LATE"},
{"STALE", false, false, "◆ STALE"}, {models.StatusStale, false, false, "◆ STALE"},
{"PENDING", false, false, "○ PENDING"}, {models.StatusPending, false, false, "○ PENDING"},
{"DOWN", true, false, "◇ PAUSED"}, {models.StatusDown, true, false, "◇ PAUSED"},
{"DOWN", false, true, "◼ MAINT"}, {models.StatusDown, false, true, "◼ MAINT"},
} }
for _, tt := range tests { for _, tt := range tests {
got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint) got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint)
+1 -1
View File
@@ -240,7 +240,7 @@ func (m Model) viewSitesTab() string {
name = limitStr(name, nameW-2) name = limitStr(name, nameW-2)
} }
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
nameLen := len([]rune(name)) nameLen := len([]rune(name))
errSpace := nameW - nameLen - 3 errSpace := nameW - nameLen - 3
if errSpace > 10 { if errSpace > 10 {
+1 -1
View File
@@ -455,7 +455,7 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
} }
period := slaPeriods[msg.periodIdx] period := slaPeriods[msg.periodIdx]
var currentStatus string var currentStatus models.Status
for _, s := range m.sites { for _, s := range m.sites {
if s.ID == msg.siteID { if s.ID == msg.siteID {
currentStatus = s.Status currentStatus = s.Status
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -16,7 +17,7 @@ func sinApprox(x float64) float64 {
func (m Model) pulseIndicator() string { func (m Model) pulseIndicator() string {
hasDown := false hasDown := false
for _, s := range m.sites { for _, s := range m.sites {
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == models.StatusDown || s.Status == models.StatusSSLExp) {
hasDown = true hasDown = true
break break
} }
@@ -127,9 +128,9 @@ func (m Model) computeStats() dashboardStats {
continue continue
} }
switch site.Status { switch site.Status {
case "DOWN", "SSL EXP": case models.StatusDown, models.StatusSSLExp:
s.downCount++ s.downCount++
case "LATE": case models.StatusLate:
s.lateCount++ s.lateCount++
} }
} }
+4 -4
View File
@@ -45,7 +45,7 @@ func (m Model) viewDetailPanel() string {
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errWidth := m.termWidth - chromePadH - 19 errWidth := m.termWidth - chromePadH - 19
if errWidth < 30 { if errWidth < 30 {
errWidth = 30 errWidth = 30
@@ -58,7 +58,7 @@ func (m Model) viewDetailPanel() string {
row("HTTP Code", strconv.Itoa(site.StatusCode)) row("HTTP Code", strconv.Itoa(site.StatusCode))
} }
if (site.Status == "DOWN" || site.Status == "SSL EXP") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 { if len(chain) > 0 {
b.WriteString("\n") b.WriteString("\n")
@@ -189,7 +189,7 @@ func (m Model) viewDetailPanel() string {
for i, sc := range stateChanges { for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt)) ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == "UP" { if sc.ToStatus == string(models.StatusUp) {
arrow += m.st.specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
} else { } else {
arrow += m.st.dangerStyle.Render(sc.ToStatus) arrow += m.st.dangerStyle.Render(sc.ToStatus)
@@ -198,7 +198,7 @@ func (m Model) viewDetailPanel() string {
if dur := computeOutageDuration(stateChanges, i); dur > 0 { if dur := computeOutageDuration(stateChanges, i); dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur)) line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
} }
if sc.ErrorReason != "" && sc.ToStatus != "UP" { if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
line += " " + m.st.dangerStyle.Render(sc.ErrorReason) line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
+6 -6
View File
@@ -17,14 +17,14 @@ type historyStats struct {
func computeOutageDuration(changes []models.StateChange, idx int) time.Duration { func computeOutageDuration(changes []models.StateChange, idx int) time.Duration {
sc := changes[idx] sc := changes[idx]
if sc.ToStatus != "UP" { if sc.ToStatus != string(models.StatusUp) {
return 0 return 0
} }
if idx+1 >= len(changes) { if idx+1 >= len(changes) {
return 0 return 0
} }
prev := changes[idx+1] prev := changes[idx+1]
if prev.ToStatus == "UP" { if prev.ToStatus == string(models.StatusUp) {
return 0 return 0
} }
dur := sc.ChangedAt.Sub(prev.ChangedAt) dur := sc.ChangedAt.Sub(prev.ChangedAt)
@@ -122,11 +122,11 @@ func (m Model) buildHistoryContent() string {
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus { switch sc.ToStatus {
case "UP": case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
case "LATE": case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus) arrow += m.st.warnStyle.Render(sc.ToStatus)
case "STALE": case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus) arrow += m.st.staleStyle.Render(sc.ToStatus)
default: default:
arrow += m.st.dangerStyle.Render(sc.ToStatus) arrow += m.st.dangerStyle.Render(sc.ToStatus)
@@ -138,7 +138,7 @@ func (m Model) buildHistoryContent() string {
} }
reason := "" reason := ""
if sc.ErrorReason != "" && sc.ToStatus != "UP" { if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
} }