From f00acbc280744bfd57d2dbee47b8b97990b23b37 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 11 Jun 2026 15:56:51 -0400 Subject: [PATCH 1/3] refactor(models): typed Status constants with IsBroken() predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~150 bare status string comparisons with typed models.Status constants (StatusUp, StatusDown, StatusPending, StatusLate, StatusStale, StatusSSLExp). Single IsBroken() method replaces the duplicated isBroken lambda in monitor.go and isDown function in sla.go. Adding a new status value (e.g. DEGRADED) now requires one constant definition instead of grep-and-pray across 16 files. CheckResult.Status stays string — the checker is the boundary between raw protocol results and typed status. Cast happens at the edge in handleStatusChange. --- internal/cluster/probe.go | 2 +- internal/metrics/prometheus.go | 7 ++- internal/models/models.go | 2 +- internal/models/status.go | 18 ++++++ internal/monitor/checker.go | 32 +++++----- internal/monitor/monitor.go | 103 ++++++++++++++++----------------- internal/monitor/sla.go | 22 +++---- internal/monitor/sla_test.go | 26 ++++----- internal/server/server.go | 10 ++-- internal/tui/format.go | 18 +++--- internal/tui/format_test.go | 18 +++--- internal/tui/tab_sites.go | 2 +- internal/tui/update.go | 2 +- internal/tui/view_dashboard.go | 7 ++- internal/tui/view_detail.go | 8 +-- internal/tui/view_history.go | 12 ++-- 16 files changed, 152 insertions(+), 137 deletions(-) create mode 100644 internal/models/status.go 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)) } -- 2.52.0 From 0974ab2b4c98dd1e60c8c6164dfb80f2ba7bec40 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 11 Jun 2026 16:02:17 -0400 Subject: [PATCH 2/3] refactor(store): schema_version migration table + DeleteAlert FK fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the error-string-matching migration runner with a proper schema_version table. Migrations are now numbered and recorded; only unapplied versions run. Fresh databases seed at baseline version (CREATE TABLE already includes all columns). CREATE TABLE statements updated to include regions (sites) and node_id (check_history) — previously only added via ALTER. DeleteAlert now nulls sites.alert_id before deleting, preventing dangling references that caused every incident to hit the error path instead of alerting. --- internal/store/dialect.go | 8 +++++- internal/store/postgres.go | 57 ++++++++++++++++++++------------------ internal/store/sqlite.go | 41 ++++++++++++++------------- internal/store/sqlstore.go | 42 +++++++++++++++++++++------- 4 files changed, 91 insertions(+), 57 deletions(-) diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 2e9ce2c..81aa157 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -5,10 +5,16 @@ import ( "strconv" ) +type Migration struct { + Version int + SQL string +} + type Dialect interface { DriverName() string CreateTablesSQL() []string - MigrationsSQL() []string + Migrations() []Migration + BaselineVersion() int BoolFalse() string ResetSequenceOnEmpty(db *sql.DB, table string) ImportWipe(tx *sql.Tx) diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 1be31ca..00511dd 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -13,8 +13,9 @@ func NewPostgresStore(connStr string) (*SQLStore, error) { return NewSQLStore("postgres", connStr, &PostgresDialect{}) } -func (d *PostgresDialect) DriverName() string { return "postgres" } -func (d *PostgresDialect) BoolFalse() string { return "FALSE" } +func (d *PostgresDialect) DriverName() string { return "postgres" } +func (d *PostgresDialect) BoolFalse() string { return "FALSE" } +func (d *PostgresDialect) BaselineVersion() int { return 21 } func (d *PostgresDialect) CreateTablesSQL() []string { return []string{ @@ -32,7 +33,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string { method TEXT DEFAULT 'GET', description TEXT DEFAULT '', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE + ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE, + regions TEXT DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -42,7 +44,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string { `CREATE TABLE IF NOT EXISTS check_history ( id SERIAL PRIMARY KEY, site_id INTEGER NOT NULL, latency_ns BIGINT, - is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW() + is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW(), + node_id TEXT DEFAULT '' )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE TABLE IF NOT EXISTS nodes ( @@ -92,29 +95,29 @@ func (d *PostgresDialect) CreateTablesSQL() []string { } } -func (d *PostgresDialect) MigrationsSQL() []string { - return []string{ - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", - "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''", - "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'", - "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'", - "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'", - "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'", - "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'", +func (d *PostgresDialect) Migrations() []Migration { + return []Migration{ + {1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"}, + {2, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0"}, + {3, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0"}, + {4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"}, + {5, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''"}, + {6, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0"}, + {7, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'"}, + {8, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''"}, + {9, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''"}, + {10, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE"}, + {11, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE"}, + {12, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''"}, + {13, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''"}, + {14, "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'"}, + {15, "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'"}, + {16, "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"}, + {17, "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'"}, + {18, "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'"}, + {19, "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"}, + {20, "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'"}, + {21, "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'"}, } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index ca294e5..53b1489 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -28,8 +28,9 @@ func NewSQLiteStore(path string) (*SQLStore, error) { return s, nil } -func (d *SQLiteDialect) DriverName() string { return "sqlite" } -func (d *SQLiteDialect) BoolFalse() string { return "0" } +func (d *SQLiteDialect) DriverName() string { return "sqlite" } +func (d *SQLiteDialect) BoolFalse() string { return "0" } +func (d *SQLiteDialect) BaselineVersion() int { return 13 } func (d *SQLiteDialect) CreateTablesSQL() []string { return []string{ @@ -47,7 +48,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { method TEXT DEFAULT 'GET', description TEXT DEFAULT '', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0 + ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0, + regions TEXT DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -57,7 +59,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { `CREATE TABLE IF NOT EXISTS check_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, latency_ns INTEGER, - is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP + is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + node_id TEXT DEFAULT '' )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE TABLE IF NOT EXISTS nodes ( @@ -107,21 +110,21 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { } } -func (d *SQLiteDialect) MigrationsSQL() []string { - return []string{ - "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'", - "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'", - "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", - "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", - "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''", +func (d *SQLiteDialect) Migrations() []Migration { + return []Migration{ + {1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"}, + {2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"}, + {3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"}, + {4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"}, + {5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"}, + {6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"}, + {7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"}, + {8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"}, + {9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"}, + {10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"}, + {11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"}, + {12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"}, + {13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"}, } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 05eff31..5821225 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -80,13 +79,34 @@ func (s *SQLStore) Init(ctx context.Context) error { return err } } - for _, m := range s.dialect.MigrationsSQL() { - if _, err := s.db.ExecContext(ctx, m); err != nil { - errMsg := err.Error() - if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") { - continue - } - return fmt.Errorf("migration failed: %w", err) + + if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + return fmt.Errorf("create schema_version: %w", err) + } + + var current int + _ = s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(¤t) //nolint:errcheck + + if current == 0 { + baseline := s.dialect.BaselineVersion() + if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), baseline); err != nil { + return fmt.Errorf("seed baseline version: %w", err) + } + current = baseline + } + + for _, m := range s.dialect.Migrations() { + if m.Version <= current { + continue + } + if _, err := s.db.ExecContext(ctx, m.SQL); err != nil { + return fmt.Errorf("migration %d failed: %w", m.Version, err) + } + if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), m.Version); err != nil { + return fmt.Errorf("record migration %d: %w", m.Version, err) } } return nil @@ -325,8 +345,10 @@ func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, } func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error { - _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id) - if err != nil { + if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil { return err } s.dialect.ResetSequenceOnEmpty(s.db, "alerts") -- 2.52.0 From 2b357341c860520325582bcd06c0ef636e0920dc Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 11 Jun 2026 16:09:29 -0400 Subject: [PATCH 3/3] refactor(store): shared storetest.BaseMock replaces 5 duplicated mocks New internal/store/storetest/mock.go provides BaseMock implementing the full Store interface with no-op defaults and optional Func field overrides. Each test file embeds BaseMock and shadows only the methods it needs. Removes ~400 lines of duplicated stub methods across 6 test files. Adding a Store method now requires one addition (BaseMock) instead of editing 6 files. --- cmd/uptop/keycache_test.go | 9 +- internal/cluster/cluster_test.go | 91 +-------- internal/metrics/prometheus_test.go | 88 +-------- internal/monitor/monitor_test.go | 67 +------ internal/server/server_test.go | 78 +------- internal/store/storetest/mock.go | 274 ++++++++++++++++++++++++++++ internal/tui/update_test.go | 90 +-------- 7 files changed, 297 insertions(+), 400 deletions(-) create mode 100644 internal/store/storetest/mock.go diff --git a/cmd/uptop/keycache_test.go b/cmd/uptop/keycache_test.go index dc10640..09af153 100644 --- a/cmd/uptop/keycache_test.go +++ b/cmd/uptop/keycache_test.go @@ -9,22 +9,21 @@ import ( "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - "gitea.lerkolabs.com/lerkolabs/uptop/internal/store" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" "github.com/charmbracelet/ssh" gossh "golang.org/x/crypto/ssh" ) -// kcMockStore implements only what keyCache and userInvalidatingStore touch; -// any other Store method panics via the embedded nil interface. +// kcMockStore embeds BaseMock for default no-ops; only GetAllUsers is +// overridden because the tests mutate users/err between calls. type kcMockStore struct { - store.Store + storetest.BaseMock users []models.User err error } func (m *kcMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, m.err } -func (m *kcMockStore) DeleteUser(_ context.Context, _ int) error { return nil } func testKey(t *testing.T) (string, ssh.PublicKey) { t.Helper() diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index a8ea1dd..bb36f4a 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -12,100 +12,13 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" ) -// --- Mock Store (minimal, for monitor.NewEngine) --- - type mockStore struct { - sites []models.Site + storetest.BaseMock } -func (m *mockStore) Init(_ context.Context) error { return nil } -func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } -func (m *mockStore) AddSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil } -func (m *mockStore) DeleteSite(_ context.Context, _ int) error { return nil } -func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { return nil, nil } -func (m *mockStore) GetAlert(_ context.Context, _ int) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *mockStore) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) DeleteAlert(_ context.Context, _ int) error { return nil } -func (m *mockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return nil, nil } -func (m *mockStore) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil } -func (m *mockStore) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) DeleteUser(_ context.Context, _ int) error { return nil } -func (m *mockStore) SaveCheck(_ context.Context, _ int, _ int64, _ bool) error { return nil } -func (m *mockStore) SaveCheckFromNode(_ context.Context, _ int, _ string, _ int64, _ bool) error { - return nil -} -func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { - return nil, nil -} -func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) { return models.Backup{}, nil } -func (m *mockStore) ImportData(_ context.Context, _ models.Backup) error { return nil } -func (m *mockStore) GetSiteByName(_ context.Context, _ string) (models.Site, error) { - return models.Site{}, nil -} -func (m *mockStore) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *mockStore) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { return 0, nil } -func (m *mockStore) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { - return 0, nil -} -func (m *mockStore) RegisterNode(_ context.Context, _ models.ProbeNode) error { return nil } -func (m *mockStore) GetNode(_ context.Context, _ string) (models.ProbeNode, error) { - return models.ProbeNode{}, nil -} -func (m *mockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) { return nil, nil } -func (m *mockStore) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil } -func (m *mockStore) DeleteNode(_ context.Context, _ string) error { return nil } -func (m *mockStore) LoadAlertHealth(_ context.Context) (map[int]models.AlertHealthRecord, error) { - return nil, nil -} -func (m *mockStore) SaveAlertHealth(_ context.Context, _ models.AlertHealthRecord) error { return nil } -func (m *mockStore) SaveLog(_ context.Context, _ string) error { return nil } -func (m *mockStore) PruneLogs(_ context.Context) error { return nil } -func (m *mockStore) PruneCheckHistory(_ context.Context) error { return nil } -func (m *mockStore) PruneStateChanges(_ context.Context) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { - return nil -} -func (m *mockStore) EndMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) { - return 0, nil -} -func (m *mockStore) IsMonitorInMaintenance(_ context.Context, _ int) (bool, error) { return false, nil } -func (m *mockStore) GetPreference(_ context.Context, _ string) (string, error) { return "", nil } -func (m *mockStore) SetPreference(_ context.Context, _ string, _ string) error { return nil } -func (m *mockStore) SaveStateChange(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) GetStateChangesSince(_ context.Context, _ int, _ time.Time) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) Close() error { return nil } - // --- Cluster Start Tests --- func TestStart_LeaderMode(t *testing.T) { diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 9352bc4..ccf3d0f 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -10,97 +10,17 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" ) type mockStore struct { + storetest.BaseMock sites []models.Site } -func (m *mockStore) Init(_ context.Context) error { return nil } -func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } -func (m *mockStore) AddSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil } -func (m *mockStore) DeleteSite(_ context.Context, _ int) error { return nil } -func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { return nil, nil } -func (m *mockStore) GetAlert(_ context.Context, _ int) (models.AlertConfig, error) { - return models.AlertConfig{}, nil +func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { + return m.sites, nil } -func (m *mockStore) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) DeleteAlert(_ context.Context, _ int) error { return nil } -func (m *mockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return nil, nil } -func (m *mockStore) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil } -func (m *mockStore) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) DeleteUser(_ context.Context, _ int) error { return nil } -func (m *mockStore) SaveCheck(_ context.Context, _ int, _ int64, _ bool) error { return nil } -func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { - return nil, nil -} -func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) { return models.Backup{}, nil } -func (m *mockStore) ImportData(_ context.Context, _ models.Backup) error { return nil } -func (m *mockStore) GetSiteByName(_ context.Context, _ string) (models.Site, error) { - return models.Site{}, nil -} -func (m *mockStore) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *mockStore) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { return 0, nil } -func (m *mockStore) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { - return 0, nil -} -func (m *mockStore) SaveCheckFromNode(_ context.Context, _ int, _ string, _ int64, _ bool) error { - return nil -} -func (m *mockStore) RegisterNode(_ context.Context, _ models.ProbeNode) error { return nil } -func (m *mockStore) GetNode(_ context.Context, _ string) (models.ProbeNode, error) { - return models.ProbeNode{}, nil -} -func (m *mockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) { return nil, nil } -func (m *mockStore) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil } -func (m *mockStore) DeleteNode(_ context.Context, _ string) error { return nil } -func (m *mockStore) LoadAlertHealth(_ context.Context) (map[int]models.AlertHealthRecord, error) { - return nil, nil -} -func (m *mockStore) SaveAlertHealth(_ context.Context, _ models.AlertHealthRecord) error { return nil } -func (m *mockStore) SaveLog(_ context.Context, _ string) error { return nil } -func (m *mockStore) PruneLogs(_ context.Context) error { return nil } -func (m *mockStore) PruneCheckHistory(_ context.Context) error { return nil } -func (m *mockStore) PruneStateChanges(_ context.Context) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { - return nil -} -func (m *mockStore) EndMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) { - return 0, nil -} -func (m *mockStore) IsMonitorInMaintenance(_ context.Context, _ int) (bool, error) { return false, nil } -func (m *mockStore) GetPreference(_ context.Context, _ string) (string, error) { return "", nil } -func (m *mockStore) SetPreference(_ context.Context, _ string, _ string) error { return nil } -func (m *mockStore) SaveStateChange(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) GetStateChangesSince(_ context.Context, _ int, _ time.Time) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) Close() error { return nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 80c5936..28f4ae0 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -8,6 +8,7 @@ import ( "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" ) // --- Mock Store --- @@ -19,6 +20,7 @@ type savedCheck struct { } type mockStore struct { + storetest.BaseMock mu sync.Mutex sites []models.Site alerts map[int]models.AlertConfig @@ -38,42 +40,8 @@ func newMockStore() *mockStore { } } -func (m *mockStore) Init(context.Context) error { return nil } -func (m *mockStore) GetSites(context.Context) ([]models.Site, error) { return m.sites, nil } -func (m *mockStore) AddSite(context.Context, models.Site) error { return nil } -func (m *mockStore) UpdateSite(context.Context, models.Site) error { return nil } -func (m *mockStore) UpdateSitePaused(context.Context, int, bool) error { return nil } -func (m *mockStore) DeleteSite(context.Context, int) error { return nil } -func (m *mockStore) AddAlert(context.Context, string, string, map[string]string) error { return nil } -func (m *mockStore) UpdateAlert(context.Context, int, string, string, map[string]string) error { - return nil -} -func (m *mockStore) DeleteAlert(context.Context, int) error { return nil } -func (m *mockStore) GetAllUsers(context.Context) ([]models.User, error) { return nil, nil } -func (m *mockStore) AddUser(context.Context, string, string, string) error { return nil } -func (m *mockStore) UpdateUser(context.Context, int, string, string, string) error { return nil } -func (m *mockStore) DeleteUser(context.Context, int) error { return nil } -func (m *mockStore) ExportData(context.Context) (models.Backup, error) { return models.Backup{}, nil } -func (m *mockStore) ImportData(context.Context, models.Backup) error { return nil } -func (m *mockStore) GetSiteByName(context.Context, string) (models.Site, error) { - return models.Site{}, nil -} -func (m *mockStore) AddSiteReturningID(context.Context, models.Site) (int, error) { return 0, nil } -func (m *mockStore) AddAlertReturningID(context.Context, string, string, map[string]string) (int, error) { - return 0, nil -} -func (m *mockStore) SaveCheckFromNode(context.Context, int, string, int64, bool) error { return nil } -func (m *mockStore) RegisterNode(context.Context, models.ProbeNode) error { return nil } -func (m *mockStore) GetNode(context.Context, string) (models.ProbeNode, error) { - return models.ProbeNode{}, nil -} -func (m *mockStore) GetAllNodes(context.Context) ([]models.ProbeNode, error) { return nil, nil } -func (m *mockStore) UpdateNodeLastSeen(context.Context, string) error { return nil } -func (m *mockStore) DeleteNode(context.Context, string) error { return nil } -func (m *mockStore) LoadAlertHealth(context.Context) (map[int]models.AlertHealthRecord, error) { - return nil, nil -} -func (m *mockStore) SaveAlertHealth(context.Context, models.AlertHealthRecord) error { return nil } +func (m *mockStore) GetSites(context.Context) ([]models.Site, error) { return m.sites, nil } + func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) { m.mu.Lock() defer m.mu.Unlock() @@ -83,25 +51,6 @@ func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.Maint } return windows, nil } -func (m *mockStore) GetAllMaintenanceWindows(context.Context, int) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) AddMaintenanceWindow(context.Context, models.MaintenanceWindow) error { return nil } -func (m *mockStore) EndMaintenanceWindow(context.Context, int) error { return nil } -func (m *mockStore) DeleteMaintenanceWindow(context.Context, int) error { return nil } -func (m *mockStore) PruneExpiredMaintenanceWindows(context.Context, time.Duration) (int64, error) { - return 0, nil -} -func (m *mockStore) GetPreference(context.Context, string) (string, error) { return "", nil } -func (m *mockStore) SetPreference(context.Context, string, string) error { return nil } -func (m *mockStore) SaveStateChange(context.Context, int, string, string, string) error { return nil } -func (m *mockStore) GetStateChanges(context.Context, int, int) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) GetStateChangesSince(context.Context, int, time.Time) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) Close() error { return nil } func (m *mockStore) GetAllAlerts(context.Context) ([]models.AlertConfig, error) { m.mu.Lock() @@ -154,18 +103,14 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, limit int) ([]string, error) { +func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return m.logs, nil } -func (m *mockStore) LoadAllHistory(_ context.Context, limit int) (map[int][]models.CheckRecord, error) { +func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { return m.history, nil } -func (m *mockStore) PruneLogs(context.Context) error { return nil } -func (m *mockStore) PruneCheckHistory(context.Context) error { return nil } -func (m *mockStore) PruneStateChanges(context.Context) error { return nil } - // --- Helpers --- func newTestEngine(ms *mockStore) *Engine { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ad14ef9..77eb81e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,11 +13,13 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" ) // --- Mock Store --- type mockStore struct { + storetest.BaseMock mu sync.Mutex sites []models.Site alerts []models.AlertConfig @@ -33,84 +35,10 @@ func newMockStore() *mockStore { } } -func (m *mockStore) Init(_ context.Context) error { return nil } -func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } -func (m *mockStore) AddSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSite(_ context.Context, _ models.Site) error { return nil } -func (m *mockStore) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil } -func (m *mockStore) DeleteSite(_ context.Context, _ int) error { return nil } +func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { return m.alerts, nil } -func (m *mockStore) GetAlert(_ context.Context, _ int) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *mockStore) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *mockStore) DeleteAlert(_ context.Context, _ int) error { return nil } -func (m *mockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return nil, nil } -func (m *mockStore) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil } -func (m *mockStore) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) DeleteUser(_ context.Context, _ int) error { return nil } -func (m *mockStore) SaveCheck(_ context.Context, _ int, _ int64, _ bool) error { return nil } -func (m *mockStore) SaveCheckFromNode(_ context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error { - return nil -} -func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { - return nil, nil -} -func (m *mockStore) GetSiteByName(_ context.Context, _ string) (models.Site, error) { - return models.Site{}, nil -} -func (m *mockStore) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *mockStore) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { return 0, nil } -func (m *mockStore) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { - return 0, nil -} -func (m *mockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) { return nil, nil } -func (m *mockStore) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil } -func (m *mockStore) DeleteNode(_ context.Context, _ string) error { return nil } -func (m *mockStore) LoadAlertHealth(_ context.Context) (map[int]models.AlertHealthRecord, error) { - return nil, nil -} -func (m *mockStore) SaveAlertHealth(_ context.Context, _ models.AlertHealthRecord) error { return nil } -func (m *mockStore) SaveLog(_ context.Context, _ string) error { return nil } -func (m *mockStore) PruneLogs(_ context.Context) error { return nil } -func (m *mockStore) PruneCheckHistory(_ context.Context) error { return nil } -func (m *mockStore) PruneStateChanges(_ context.Context) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *mockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *mockStore) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { - return nil -} -func (m *mockStore) EndMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *mockStore) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) { - return 0, nil -} -func (m *mockStore) IsMonitorInMaintenance(_ context.Context, _ int) (bool, error) { return false, nil } -func (m *mockStore) GetPreference(_ context.Context, _ string) (string, error) { return "", nil } -func (m *mockStore) SetPreference(_ context.Context, _ string, _ string) error { return nil } -func (m *mockStore) SaveStateChange(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *mockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) GetStateChangesSince(_ context.Context, _ int, _ time.Time) ([]models.StateChange, error) { - return nil, nil -} -func (m *mockStore) Close() error { return nil } func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) { return models.Backup{ diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go new file mode 100644 index 0000000..d8686f9 --- /dev/null +++ b/internal/store/storetest/mock.go @@ -0,0 +1,274 @@ +package storetest + +import ( + "context" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +// BaseMock implements store.Store with no-op defaults. Embed it in test-specific +// mocks and override only the methods you need via the exported Func fields or +// by shadowing the method on the embedding struct. +type BaseMock struct { + GetSitesFunc func(ctx context.Context) ([]models.Site, error) + AddSiteFunc func(ctx context.Context, site models.Site) error + UpdateSiteFunc func(ctx context.Context, site models.Site) error + GetAllAlertsFunc func(ctx context.Context) ([]models.AlertConfig, error) + GetAlertFunc func(ctx context.Context, id int) (models.AlertConfig, error) + GetAllUsersFunc func(ctx context.Context) ([]models.User, error) + GetAllNodesFunc func(ctx context.Context) ([]models.ProbeNode, error) + GetActiveMaintenanceWindowsFunc func(ctx context.Context) ([]models.MaintenanceWindow, error) + GetAllMaintenanceWindowsFunc func(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) + IsMonitorInMaintenanceFunc func(ctx context.Context, id int) (bool, error) + LoadAlertHealthFunc func(ctx context.Context) (map[int]models.AlertHealthRecord, error) + LoadAllHistoryFunc func(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) + SaveCheckFunc func(ctx context.Context, siteID int, latencyNs int64, isUp bool) error + SaveCheckFromNodeFunc func(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error + SaveLogFunc func(ctx context.Context, message string) error + SaveStateChangeFunc func(ctx context.Context, siteID int, from, to, reason string) error + SaveAlertHealthFunc func(ctx context.Context, h models.AlertHealthRecord) error + GetStateChangesFunc func(ctx context.Context, siteID, limit int) ([]models.StateChange, error) + GetStateChangesSinceFunc func(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) + ExportDataFunc func(ctx context.Context) (models.Backup, error) + ImportDataFunc func(ctx context.Context, data models.Backup) error + RegisterNodeFunc func(ctx context.Context, node models.ProbeNode) error + GetNodeFunc func(ctx context.Context, id string) (models.ProbeNode, error) + GetPreferenceFunc func(ctx context.Context, key string) (string, error) + SetPreferenceFunc func(ctx context.Context, key, value string) error +} + +func (m *BaseMock) Init(_ context.Context) error { return nil } +func (m *BaseMock) Close() error { return nil } + +func (m *BaseMock) GetSites(ctx context.Context) ([]models.Site, error) { + if m.GetSitesFunc != nil { + return m.GetSitesFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) AddSite(ctx context.Context, site models.Site) error { + if m.AddSiteFunc != nil { + return m.AddSiteFunc(ctx, site) + } + return nil +} + +func (m *BaseMock) UpdateSite(ctx context.Context, site models.Site) error { + if m.UpdateSiteFunc != nil { + return m.UpdateSiteFunc(ctx, site) + } + return nil +} + +func (m *BaseMock) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil } + +func (m *BaseMock) DeleteSite(_ context.Context, _ int) error { return nil } + +func (m *BaseMock) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) { + if m.GetAllAlertsFunc != nil { + return m.GetAllAlertsFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) { + if m.GetAlertFunc != nil { + return m.GetAlertFunc(ctx, id) + } + return models.AlertConfig{}, nil +} + +func (m *BaseMock) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error { + return nil +} + +func (m *BaseMock) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error { + return nil +} + +func (m *BaseMock) DeleteAlert(_ context.Context, _ int) error { return nil } + +func (m *BaseMock) GetSiteByName(_ context.Context, _ string) (models.Site, error) { + return models.Site{}, nil +} + +func (m *BaseMock) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) { + return models.AlertConfig{}, nil +} + +func (m *BaseMock) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { return 0, nil } + +func (m *BaseMock) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { + return 0, nil +} + +func (m *BaseMock) GetAllUsers(ctx context.Context) ([]models.User, error) { + if m.GetAllUsersFunc != nil { + return m.GetAllUsersFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil } + +func (m *BaseMock) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error { + return nil +} + +func (m *BaseMock) DeleteUser(_ context.Context, _ int) error { return nil } + +func (m *BaseMock) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error { + if m.SaveCheckFunc != nil { + return m.SaveCheckFunc(ctx, siteID, latencyNs, isUp) + } + return nil +} + +func (m *BaseMock) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error { + if m.SaveCheckFromNodeFunc != nil { + return m.SaveCheckFromNodeFunc(ctx, siteID, nodeID, latencyNs, isUp) + } + return nil +} + +func (m *BaseMock) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { + if m.LoadAllHistoryFunc != nil { + return m.LoadAllHistoryFunc(ctx, limit) + } + return nil, nil +} + +func (m *BaseMock) PruneCheckHistory(_ context.Context) error { return nil } + +func (m *BaseMock) SaveStateChange(ctx context.Context, siteID int, from, to, reason string) error { + if m.SaveStateChangeFunc != nil { + return m.SaveStateChangeFunc(ctx, siteID, from, to, reason) + } + return nil +} + +func (m *BaseMock) GetStateChanges(ctx context.Context, siteID, limit int) ([]models.StateChange, error) { + if m.GetStateChangesFunc != nil { + return m.GetStateChangesFunc(ctx, siteID, limit) + } + return nil, nil +} + +func (m *BaseMock) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) { + if m.GetStateChangesSinceFunc != nil { + return m.GetStateChangesSinceFunc(ctx, siteID, since) + } + return nil, nil +} + +func (m *BaseMock) PruneStateChanges(_ context.Context) error { return nil } + +func (m *BaseMock) RegisterNode(ctx context.Context, node models.ProbeNode) error { + if m.RegisterNodeFunc != nil { + return m.RegisterNodeFunc(ctx, node) + } + return nil +} + +func (m *BaseMock) GetNode(ctx context.Context, id string) (models.ProbeNode, error) { + if m.GetNodeFunc != nil { + return m.GetNodeFunc(ctx, id) + } + return models.ProbeNode{}, nil +} + +func (m *BaseMock) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) { + if m.GetAllNodesFunc != nil { + return m.GetAllNodesFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil } +func (m *BaseMock) DeleteNode(_ context.Context, _ string) error { return nil } + +func (m *BaseMock) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) { + if m.LoadAlertHealthFunc != nil { + return m.LoadAlertHealthFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error { + if m.SaveAlertHealthFunc != nil { + return m.SaveAlertHealthFunc(ctx, h) + } + return nil +} + +func (m *BaseMock) SaveLog(ctx context.Context, message string) error { + if m.SaveLogFunc != nil { + return m.SaveLogFunc(ctx, message) + } + return nil +} + +func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } +func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } + +func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { + if m.GetActiveMaintenanceWindowsFunc != nil { + return m.GetActiveMaintenanceWindowsFunc(ctx) + } + return nil, nil +} + +func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) { + if m.GetAllMaintenanceWindowsFunc != nil { + return m.GetAllMaintenanceWindowsFunc(ctx, limit) + } + return nil, nil +} + +func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { + return nil +} + +func (m *BaseMock) EndMaintenanceWindow(_ context.Context, _ int) error { return nil } +func (m *BaseMock) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil } + +func (m *BaseMock) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) { + return 0, nil +} + +func (m *BaseMock) IsMonitorInMaintenance(ctx context.Context, id int) (bool, error) { + if m.IsMonitorInMaintenanceFunc != nil { + return m.IsMonitorInMaintenanceFunc(ctx, id) + } + return false, nil +} + +func (m *BaseMock) GetPreference(ctx context.Context, key string) (string, error) { + if m.GetPreferenceFunc != nil { + return m.GetPreferenceFunc(ctx, key) + } + return "", nil +} + +func (m *BaseMock) SetPreference(ctx context.Context, key, value string) error { + if m.SetPreferenceFunc != nil { + return m.SetPreferenceFunc(ctx, key, value) + } + return nil +} + +func (m *BaseMock) ExportData(ctx context.Context) (models.Backup, error) { + if m.ExportDataFunc != nil { + return m.ExportDataFunc(ctx) + } + return models.Backup{}, nil +} + +func (m *BaseMock) ImportData(ctx context.Context, data models.Backup) error { + if m.ImportDataFunc != nil { + return m.ImportDataFunc(ctx, data) + } + return nil +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index cc82a49..f08338d 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -8,6 +8,7 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" ) @@ -15,13 +16,14 @@ import ( // --- minimal Store mock for TUI data-flow tests --- type tuiMockStore struct { + storetest.BaseMock alerts []models.AlertConfig users []models.User nodes []models.ProbeNode maint []models.MaintenanceWindow stateChanges []models.StateChange - stateChangeCalls int // counts GetStateChanges hits (to prove View does no IO) - deleteSiteCalls int // counts DeleteSite hits (to prove writes run in Cmds) + stateChangeCalls int + deleteSiteCalls int } func (m *tuiMockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { @@ -38,94 +40,10 @@ func (m *tuiMockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]model func (m *tuiMockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) { return m.maint, nil } - -func (m *tuiMockStore) Init(_ context.Context) error { return nil } -func (m *tuiMockStore) GetSites(_ context.Context) ([]models.Site, error) { return nil, nil } -func (m *tuiMockStore) AddSite(_ context.Context, _ models.Site) error { return nil } -func (m *tuiMockStore) UpdateSite(_ context.Context, _ models.Site) error { return nil } -func (m *tuiMockStore) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil } func (m *tuiMockStore) DeleteSite(_ context.Context, _ int) error { m.deleteSiteCalls++ return nil } -func (m *tuiMockStore) GetAlert(_ context.Context, _ int) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *tuiMockStore) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *tuiMockStore) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error { - return nil -} -func (m *tuiMockStore) DeleteAlert(_ context.Context, _ int) error { return nil } -func (m *tuiMockStore) GetSiteByName(_ context.Context, _ string) (models.Site, error) { - return models.Site{}, nil -} -func (m *tuiMockStore) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) { - return models.AlertConfig{}, nil -} -func (m *tuiMockStore) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { - return 0, nil -} -func (m *tuiMockStore) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { - return 0, nil -} -func (m *tuiMockStore) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil } -func (m *tuiMockStore) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *tuiMockStore) DeleteUser(_ context.Context, _ int) error { return nil } -func (m *tuiMockStore) SaveCheck(_ context.Context, _ int, _ int64, _ bool) error { return nil } -func (m *tuiMockStore) SaveCheckFromNode(_ context.Context, _ int, _ string, _ int64, _ bool) error { - return nil -} -func (m *tuiMockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { - return nil, nil -} -func (m *tuiMockStore) PruneCheckHistory(_ context.Context) error { return nil } -func (m *tuiMockStore) SaveStateChange(_ context.Context, _ int, _ string, _ string, _ string) error { - return nil -} -func (m *tuiMockStore) GetStateChangesSince(_ context.Context, _ int, _ time.Time) ([]models.StateChange, error) { - return nil, nil -} -func (m *tuiMockStore) PruneStateChanges(_ context.Context) error { return nil } -func (m *tuiMockStore) RegisterNode(_ context.Context, _ models.ProbeNode) error { return nil } -func (m *tuiMockStore) GetNode(_ context.Context, _ string) (models.ProbeNode, error) { - return models.ProbeNode{}, nil -} -func (m *tuiMockStore) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil } -func (m *tuiMockStore) DeleteNode(_ context.Context, _ string) error { return nil } -func (m *tuiMockStore) LoadAlertHealth(_ context.Context) (map[int]models.AlertHealthRecord, error) { - return nil, nil -} -func (m *tuiMockStore) SaveAlertHealth(_ context.Context, _ models.AlertHealthRecord) error { - return nil -} -func (m *tuiMockStore) SaveLog(_ context.Context, _ string) error { return nil } -func (m *tuiMockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *tuiMockStore) PruneLogs(_ context.Context) error { return nil } -func (m *tuiMockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) { - return nil, nil -} -func (m *tuiMockStore) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { - return nil -} -func (m *tuiMockStore) EndMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *tuiMockStore) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil } -func (m *tuiMockStore) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) { - return 0, nil -} -func (m *tuiMockStore) IsMonitorInMaintenance(_ context.Context, _ int) (bool, error) { - return false, nil -} -func (m *tuiMockStore) GetPreference(_ context.Context, _ string) (string, error) { return "", nil } -func (m *tuiMockStore) SetPreference(_ context.Context, _ string, _ string) error { return nil } -func (m *tuiMockStore) ExportData(_ context.Context) (models.Backup, error) { - return models.Backup{}, nil -} -func (m *tuiMockStore) ImportData(_ context.Context, _ models.Backup) error { return nil } -func (m *tuiMockStore) Close() error { return nil } func newTestModel(ms *tuiMockStore) Model { return Model{ -- 2.52.0