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