diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go index 41828ae..93dbf66 100644 --- a/internal/cluster/probe.go +++ b/internal/cluster/probe.go @@ -157,7 +157,7 @@ loop: results = append(results, probeResultItem{ SiteID: s.ID, LatencyNs: cr.LatencyNs, - IsUp: cr.Status == "UP", + IsUp: cr.Status == string(models.StatusUp), ErrorReason: cr.ErrorReason, }) mu.Unlock() diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index 05446f4..235b152 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -2,11 +2,12 @@ package metrics import ( "fmt" - "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "net/http" "sort" "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" ) 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).") for _, s := range sites { val := 0 - if s.Status == "UP" { + if s.Status == models.StatusUp { val = 1 } writeGauge(&b, "uptop_monitor_up", labels(s), float64(val)) diff --git a/internal/models/models.go b/internal/models/models.go index 98c1be5..668b9dd 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -28,7 +28,7 @@ type Site struct { Regions string FailureCount int - Status string + Status Status StatusCode int Latency time.Duration CertExpiry time.Time diff --git a/internal/models/status.go b/internal/models/status.go new file mode 100644 index 0000000..50275c1 --- /dev/null +++ b/internal/models/status.go @@ -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) } diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go index 48abc97..13abce2 100644 --- a/internal/monitor/checker.go +++ b/internal/monitor/checker.go @@ -47,7 +47,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie if ips, err := net.LookupIP(host); err == nil { for _, ip := range ips { 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": return runDNSCheck(ctx, site) 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) 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 @@ -94,12 +94,12 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http. result := CheckResult{ SiteID: site.ID, - Status: "UP", + Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds(), } if err != nil { - result.Status = "DOWN" + result.Status = string(models.StatusDown) result.ErrorReason = truncateError(err.Error(), maxErrorLength) return result } @@ -107,7 +107,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http. result.StatusCode = resp.StatusCode if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { - result.Status = "DOWN" + result.Status = string(models.StatusDown) expected := site.AcceptedCodes if expected == "" { expected = defaultAcceptedCodes @@ -120,7 +120,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http. cert := resp.TLS.PeerCertificates[0] result.CertExpiry = cert.NotAfter if time.Now().After(cert.NotAfter) { - result.Status = "SSL EXP" + result.Status = string(models.StatusSSLExp) result.ErrorReason = "SSL certificate expired" } } @@ -136,7 +136,7 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult { pinger, err := probing.NewPinger(host) 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.Timeout = siteTimeout(site) @@ -147,14 +147,14 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult { latency := time.Since(start) 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 { - 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() - 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 { @@ -170,10 +170,10 @@ func runPortCheck(_ context.Context, site models.Site) CheckResult { latency := time.Since(start) 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() - 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 { @@ -221,12 +221,12 @@ func runDNSCheck(_ context.Context, site models.Site) CheckResult { latency := time.Since(start) 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 { - 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 { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index c9ae786..ff7cca3 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -334,7 +334,7 @@ func (e *Engine) RecordHeartbeat(token string) bool { } var ( - prevStatus string + prevStatus models.Status name string alertID int downSince time.Time @@ -346,12 +346,12 @@ func (e *Engine) RecordHeartbeat(token string) bool { downSince = s.StatusChangedAt // captured before mutation = when it went down s.LastCheck = time.Now() - s.Status = "UP" + s.Status = models.StatusUp s.FailureCount = 0 s.Latency = 0 s.LastError = "" s.LastSuccessAt = time.Now() - if prevStatus != "UP" { + if prevStatus != models.StatusUp { s.StatusChangedAt = time.Now() } }) @@ -360,13 +360,13 @@ func (e *Engine) RecordHeartbeat(token string) bool { } switch prevStatus { - case "PENDING": + case models.StatusPending: 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)) - case "STALE": + case models.StatusStale: e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name)) - case "DOWN": + case models.StatusDown: downDur := "" if !downSince.IsZero() { 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)) } - if prevStatus != "UP" && prevStatus != "PENDING" { - e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: prevStatus, toStatus: "UP"}) + if prevStatus != models.StatusUp && prevStatus != models.StatusPending { + e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)}) } return true @@ -434,12 +434,12 @@ func (e *Engine) Start(ctx context.Context) { e.mu.RUnlock() if !exists { e.mu.Lock() - s.Status = "PENDING" + s.Status = models.StatusPending if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { if h.Statuses[len(h.Statuses)-1] { - s.Status = "UP" + s.Status = models.StatusUp } else { - s.Status = "DOWN" + s.Status = models.StatusDown } if len(h.Latencies) > 0 { 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) { - if site.Status == "PENDING" { + if site.Status == models.StatusPending { return } @@ -702,16 +702,16 @@ func (e *Engine) checkPush(_ context.Context, site models.Site) { now := time.Now() if now.After(graceEnd) { - if site.Status != "DOWN" { - e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed") + if site.Status != models.StatusDown { + e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed") } } else if now.After(staleMark) { - if site.Status != "STALE" { - e.handleStatusChange(site, "STALE", 0, 0, "heartbeat stale") + if site.Status != models.StatusStale { + e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale") } } else if now.After(overdue) { - if site.Status != "LATE" { - e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue") + if site.Status != models.StatusLate { + 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) + status := models.Status(rawStatus) var ( - prev, next string + prev, next models.Status name, typ string alertID 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) { // A non-UP result computed from a stale snapshot must not override a // 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 return } @@ -764,24 +765,24 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int s.HasSSL = snap.HasSSL s.CertExpiry = snap.CertExpiry s.LastError = errorReason - if rawStatus == "UP" { + if status == models.StatusUp { s.LastSuccessAt = time.Now() s.LastError = "" } // Status + failure-count transition, based on the CURRENT live status. - if rawStatus == "UP" { + if status == models.StatusUp { s.FailureCount = 0 - s.Status = "UP" + s.Status = models.StatusUp } else { if s.FailureCount <= s.MaxRetries { s.FailureCount++ } if s.FailureCount > s.MaxRetries { - if s.Status != rawStatus { + if s.Status != status { confirmedDown = true } - s.Status = rawStatus + s.Status = status s.FailureCount = s.MaxRetries + 1 } else { failedCheck = true @@ -789,16 +790,16 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int } failCount = s.FailureCount - if s.Status != prev && prev != "PENDING" { + if s.Status != prev && prev != models.StatusPending { 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() } // SSL expiry warning (fresh HasSSL/CertExpiry + config threshold). if typ == "http" && s.CheckSSL && s.HasSSL { 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 sslDays = days s.SentSSLWarning = true @@ -815,7 +816,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int return } - e.recordCheck(snap.ID, latency, rawStatus == "UP") + e.recordCheck(snap.ID, latency, status == models.StatusUp) if confirmedDown { 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)) } - if changed && prev != "PENDING" { - e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: prev, toStatus: next, reason: errorReason}) + if changed && prev != models.StatusPending { + e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason}) } 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 == "UP" && next == "LATE" { + if prev == models.StatusUp && next == models.StatusLate { 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 { e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name)) } else { @@ -859,7 +858,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int e.triggerAlert(alertID, "🚨 ALERT", msg) } } - if isBroken(prev) && next == "UP" { + if prev.IsBroken() && next == models.StatusUp { downDur := "" if !downSince.IsZero() { 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)) } } - 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)) } } @@ -991,12 +990,12 @@ func (e *Engine) GetDisplayStatus(site models.Site) string { if e.isInMaintenance(site.ID) { return "MAINT" } - return site.Status + return string(site.Status) } func (e *Engine) checkGroup(_ context.Context, site models.Site) { e.mu.RLock() - status := "UP" + status := models.StatusUp hasChildren := false for _, child := range e.liveState { 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) { continue } - if child.Status == "DOWN" || child.Status == "SSL EXP" { - status = "DOWN" - } else if child.Status == "STALE" && status != "DOWN" { - status = "STALE" - } else if child.Status == "LATE" && status != "DOWN" && status != "STALE" { - status = "LATE" - } else if child.Status == "PENDING" && status != "DOWN" && status != "STALE" && status != "LATE" { - status = "PENDING" + if child.Status == models.StatusDown || child.Status == models.StatusSSLExp { + status = models.StatusDown + } else if child.Status == models.StatusStale && status != models.StatusDown { + status = models.StatusStale + } else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale { + status = models.StatusLate + } else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate { + status = models.StatusPending } } e.mu.RUnlock() if !hasChildren { - status = "PENDING" + status = models.StatusPending } 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) - rawStatus := "UP" + probeStatus := models.StatusUp if !aggUp { - rawStatus = "DOWN" + probeStatus = models.StatusDown } updatedSite := site updatedSite.Latency = time.Duration(avgLatency) 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 { diff --git a/internal/monitor/sla.go b/internal/monitor/sla.go index 90656cf..cfa815a 100644 --- a/internal/monitor/sla.go +++ b/internal/monitor/sla.go @@ -16,14 +16,14 @@ type SLAReport struct { 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() windowStart := now.Add(-window) report := SLAReport{Window: window} if len(changes) == 0 { - if isDown(currentStatus) { + if models.Status(currentStatus).IsBroken() { report.UptimePct = 0 report.Downtime = window } 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. - statusAtStart := "UP" + statusAtStart := string(models.StatusUp) for i := len(sorted) - 1; i >= 0; i-- { if !sorted[i].ChangedAt.After(windowStart) { statusAtStart = sorted[i].ToStatus @@ -51,7 +51,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time. var upTime, downTime time.Duration var outages []time.Duration cursor := windowStart - wasDown := isDown(statusAtStart) + wasDown := models.Status(statusAtStart).IsBroken() if wasDown { report.OutageCount = 1 @@ -77,7 +77,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time. upTime += seg } - newDown := isDown(sc.ToStatus) + newDown := models.Status(sc.ToStatus).IsBroken() if !wasDown && newDown { report.OutageCount++ outageStart = sc.ChangedAt @@ -127,7 +127,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time. 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) for i := 0; i < days; i++ { @@ -159,10 +159,6 @@ type DayReport struct { UptimePct float64 } -func isDown(status string) bool { - return status == "DOWN" || status == "SSL EXP" -} - func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange { var filtered []models.StateChange for _, sc := range changes { @@ -180,7 +176,7 @@ func inferStatusAt(changes []models.StateChange, at time.Time) string { return sc.ToStatus } } - return "UP" + return string(models.StatusUp) } 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 cursor := start - wasDown := isDown(statusAtStart) + wasDown := models.Status(statusAtStart).IsBroken() for _, sc := range sorted { if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) { @@ -205,7 +201,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta } else { upTime += seg } - wasDown = isDown(sc.ToStatus) + wasDown = models.Status(sc.ToStatus).IsBroken() cursor = sc.ChangedAt } diff --git a/internal/monitor/sla_test.go b/internal/monitor/sla_test.go index 474971a..5edfe11 100644 --- a/internal/monitor/sla_test.go +++ b/internal/monitor/sla_test.go @@ -137,24 +137,24 @@ func TestComputeDailyBreakdown(t *testing.T) { } } -func TestIsDown(t *testing.T) { - if !isDown("DOWN") { - t.Error("DOWN should be down") +func TestIsBroken(t *testing.T) { + if !models.StatusDown.IsBroken() { + t.Error("DOWN should be broken") } - if !isDown("SSL EXP") { - t.Error("SSL EXP should be down") + if !models.StatusSSLExp.IsBroken() { + t.Error("SSL EXP should be broken") } - if isDown("UP") { - t.Error("UP should not be down") + if models.StatusUp.IsBroken() { + t.Error("UP should not be broken") } - if isDown("LATE") { - t.Error("LATE should not be down") + if models.StatusLate.IsBroken() { + t.Error("LATE should not be broken") } - if isDown("STALE") { - t.Error("STALE should not be down") + if models.StatusStale.IsBroken() { + t.Error("STALE should not be broken") } - if isDown("PENDING") { - t.Error("PENDING should not be down") + if models.StatusPending.IsBroken() { + t.Error("PENDING should not be broken") } } diff --git a/internal/server/server.go b/internal/server/server.go index 6538e41..c8c3414 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -468,15 +468,15 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { } public := make(map[int]statusSite, len(state)) for id, site := range state { - status := site.Status + displayStatus := string(site.Status) if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { - status = "MAINT" + displayStatus = "MAINT" } public[id] = statusSite{ Name: site.Name, Type: site.Type, URL: site.URL, - Status: status, + Status: displayStatus, Paused: site.Paused, LastCheck: site.LastCheck, 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 { if sites[i].Status != sites[j].Status { - if sites[i].Status == "DOWN" { + if sites[i].Status == models.StatusDown { return true } - if sites[j].Status == "DOWN" { + if sites[j].Status == models.StatusDown { return false } } diff --git a/internal/tui/format.go b/internal/tui/format.go index 660ba10..591371a 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -143,16 +143,16 @@ func (m Model) fmtRetries(site models.Site) string { 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) } - if site.Status == "UP" && site.FailureCount > 0 { + if site.Status == models.StatusUp && site.FailureCount > 0 { return m.st.warnStyle.Render(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 { 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") } switch status { - case "DOWN": + case models.StatusDown: return m.st.dangerStyle.Render("▼ DOWN") - case "SSL EXP": + case models.StatusSSLExp: return m.st.dangerStyle.Render("▼ SSL EXP") - case "LATE": + case models.StatusLate: return m.st.warnStyle.Render("◆ LATE") - case "STALE": + case models.StatusStale: return m.st.staleStyle.Render("◆ STALE") - case "PENDING": + case models.StatusPending: return m.st.subtleStyle.Render("○ PENDING") default: - return m.st.specialStyle.Render("▲ " + status) + return m.st.specialStyle.Render("▲ " + string(status)) } } diff --git a/internal/tui/format_test.go b/internal/tui/format_test.go index 9ee8d36..7c02778 100644 --- a/internal/tui/format_test.go +++ b/internal/tui/format_test.go @@ -56,19 +56,19 @@ func TestSiteOrder(t *testing.T) { func TestFmtStatus(t *testing.T) { tests := []struct { - status string + status models.Status paused bool inMaint bool wantSub string }{ - {"DOWN", false, false, "▼ DOWN"}, - {"UP", false, false, "▲ UP"}, - {"SSL EXP", false, false, "▼ SSL EXP"}, - {"LATE", false, false, "◆ LATE"}, - {"STALE", false, false, "◆ STALE"}, - {"PENDING", false, false, "○ PENDING"}, - {"DOWN", true, false, "◇ PAUSED"}, - {"DOWN", false, true, "◼ MAINT"}, + {models.StatusDown, false, false, "▼ DOWN"}, + {models.StatusUp, false, false, "▲ UP"}, + {models.StatusSSLExp, false, false, "▼ SSL EXP"}, + {models.StatusLate, false, false, "◆ LATE"}, + {models.StatusStale, false, false, "◆ STALE"}, + {models.StatusPending, false, false, "○ PENDING"}, + {models.StatusDown, true, false, "◇ PAUSED"}, + {models.StatusDown, false, true, "◼ MAINT"}, } for _, tt := range tests { got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 6c970ee..c84a270 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -240,7 +240,7 @@ func (m Model) viewSitesTab() string { 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)) errSpace := nameW - nameLen - 3 if errSpace > 10 { diff --git a/internal/tui/update.go b/internal/tui/update.go index b855a9f..63f019b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -455,7 +455,7 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) { } period := slaPeriods[msg.periodIdx] - var currentStatus string + var currentStatus models.Status for _, s := range m.sites { if s.ID == msg.siteID { currentStatus = s.Status diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 18fd814..4335dad 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/charmbracelet/lipgloss" ) @@ -16,7 +17,7 @@ func sinApprox(x float64) float64 { func (m Model) pulseIndicator() string { hasDown := false 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 break } @@ -127,9 +128,9 @@ func (m Model) computeStats() dashboardStats { continue } switch site.Status { - case "DOWN", "SSL EXP": + case models.StatusDown, models.StatusSSLExp: s.downCount++ - case "LATE": + case models.StatusLate: s.lateCount++ } } diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 44c9abf..081fd40 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -45,7 +45,7 @@ func (m Model) viewDetailPanel() string { 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 if errWidth < 30 { errWidth = 30 @@ -58,7 +58,7 @@ func (m Model) viewDetailPanel() string { 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")) if len(chain) > 0 { b.WriteString("\n") @@ -189,7 +189,7 @@ func (m Model) viewDetailPanel() string { for i, sc := range stateChanges { ago := fmtDuration(time.Since(sc.ChangedAt)) arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " - if sc.ToStatus == "UP" { + if sc.ToStatus == string(models.StatusUp) { arrow += m.st.specialStyle.Render(sc.ToStatus) } else { arrow += m.st.dangerStyle.Render(sc.ToStatus) @@ -198,7 +198,7 @@ func (m Model) viewDetailPanel() string { if dur := computeOutageDuration(stateChanges, i); dur > 0 { 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) } b.WriteString(line + "\n") diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index f61d7d6..9efddcb 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -17,14 +17,14 @@ type historyStats struct { func computeOutageDuration(changes []models.StateChange, idx int) time.Duration { sc := changes[idx] - if sc.ToStatus != "UP" { + if sc.ToStatus != string(models.StatusUp) { return 0 } if idx+1 >= len(changes) { return 0 } prev := changes[idx+1] - if prev.ToStatus == "UP" { + if prev.ToStatus == string(models.StatusUp) { return 0 } dur := sc.ChangedAt.Sub(prev.ChangedAt) @@ -122,11 +122,11 @@ func (m Model) buildHistoryContent() string { arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " switch sc.ToStatus { - case "UP": + case string(models.StatusUp): arrow += m.st.specialStyle.Render(sc.ToStatus) - case "LATE": + case string(models.StatusLate): arrow += m.st.warnStyle.Render(sc.ToStatus) - case "STALE": + case string(models.StatusStale): arrow += m.st.staleStyle.Render(sc.ToStatus) default: arrow += m.st.dangerStyle.Render(sc.ToStatus) @@ -138,7 +138,7 @@ func (m Model) buildHistoryContent() string { } reason := "" - if sc.ErrorReason != "" && sc.ToStatus != "UP" { + if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) { reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) }