Compare commits

3 Commits

Author SHA1 Message Date
lerko 2b357341c8 refactor(store): shared storetest.BaseMock replaces 5 duplicated mocks
CI / test (pull_request) Successful in 1m57s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 1m1s
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.
2026-06-11 16:09:29 -04:00
lerko 0974ab2b4c refactor(store): schema_version migration table + DeleteAlert FK fix
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.
2026-06-11 16:02:17 -04:00
lerko f00acbc280 refactor(models): typed Status constants with IsBroken() predicate
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.
2026-06-11 15:56:51 -04:00
27 changed files with 540 additions and 594 deletions
+4 -5
View File
@@ -9,22 +9,21 @@ import (
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "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" "github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
// kcMockStore implements only what keyCache and userInvalidatingStore touch; // kcMockStore embeds BaseMock for default no-ops; only GetAllUsers is
// any other Store method panics via the embedded nil interface. // overridden because the tests mutate users/err between calls.
type kcMockStore struct { type kcMockStore struct {
store.Store storetest.BaseMock
users []models.User users []models.User
err error err error
} }
func (m *kcMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, m.err } 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) { func testKey(t *testing.T) (string, ssh.PublicKey) {
t.Helper() t.Helper()
+2 -89
View File
@@ -12,100 +12,13 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store (minimal, for monitor.NewEngine) ---
type mockStore struct { 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 --- // --- Cluster Start Tests ---
func TestStart_LeaderMode(t *testing.T) { func TestStart_LeaderMode(t *testing.T) {
+1 -1
View File
@@ -157,7 +157,7 @@ loop:
results = append(results, probeResultItem{ results = append(results, probeResultItem{
SiteID: s.ID, SiteID: s.ID,
LatencyNs: cr.LatencyNs, LatencyNs: cr.LatencyNs,
IsUp: cr.Status == "UP", IsUp: cr.Status == string(models.StatusUp),
ErrorReason: cr.ErrorReason, ErrorReason: cr.ErrorReason,
}) })
mu.Unlock() mu.Unlock()
+4 -3
View File
@@ -2,11 +2,12 @@ package metrics
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
) )
func Handler(eng *monitor.Engine) http.HandlerFunc { func Handler(eng *monitor.Engine) http.HandlerFunc {
@@ -19,7 +20,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == "UP" { if s.Status == models.StatusUp {
val = 1 val = 1
} }
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
+4 -84
View File
@@ -10,97 +10,17 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
type mockStore struct { type mockStore struct {
storetest.BaseMock
sites []models.Site sites []models.Site
} }
func (m *mockStore) Init(_ context.Context) error { return nil } func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) {
func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } 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) 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) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
+1 -1
View File
@@ -28,7 +28,7 @@ type Site struct {
Regions string Regions string
FailureCount int FailureCount int
Status string Status Status
StatusCode int StatusCode int
Latency time.Duration Latency time.Duration
CertExpiry time.Time CertExpiry time.Time
+18
View File
@@ -0,0 +1,18 @@
package models
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
StatusPending Status = "PENDING"
StatusLate Status = "LATE"
StatusStale Status = "STALE"
StatusSSLExp Status = "SSL EXP"
)
func (s Status) IsBroken() bool {
return s == StatusDown || s == StatusSSLExp
}
func (s Status) String() string { return string(s) }
+16 -16
View File
@@ -47,7 +47,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie
if ips, err := net.LookupIP(host); err == nil { if ips, err := net.LookupIP(host); err == nil {
for _, ip := range ips { for _, ip := range ips {
if isPrivateIP(ip) { if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "target resolves to private IP"}
} }
} }
} }
@@ -64,7 +64,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie
case "dns": case "dns":
return runDNSCheck(ctx, site) return runDNSCheck(ctx, site)
default: default:
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "unsupported monitor type: " + site.Type}
} }
} }
@@ -80,7 +80,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "invalid request: " + err.Error()}
} }
client := strict client := strict
@@ -94,12 +94,12 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
result := CheckResult{ result := CheckResult{
SiteID: site.ID, SiteID: site.ID,
Status: "UP", Status: string(models.StatusUp),
LatencyNs: latency.Nanoseconds(), LatencyNs: latency.Nanoseconds(),
} }
if err != nil { if err != nil {
result.Status = "DOWN" result.Status = string(models.StatusDown)
result.ErrorReason = truncateError(err.Error(), maxErrorLength) result.ErrorReason = truncateError(err.Error(), maxErrorLength)
return result return result
} }
@@ -107,7 +107,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
result.StatusCode = resp.StatusCode result.StatusCode = resp.StatusCode
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
result.Status = "DOWN" result.Status = string(models.StatusDown)
expected := site.AcceptedCodes expected := site.AcceptedCodes
if expected == "" { if expected == "" {
expected = defaultAcceptedCodes expected = defaultAcceptedCodes
@@ -120,7 +120,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.
cert := resp.TLS.PeerCertificates[0] cert := resp.TLS.PeerCertificates[0]
result.CertExpiry = cert.NotAfter result.CertExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) { if time.Now().After(cert.NotAfter) {
result.Status = "SSL EXP" result.Status = string(models.StatusSSLExp)
result.ErrorReason = "SSL certificate expired" result.ErrorReason = "SSL certificate expired"
} }
} }
@@ -136,7 +136,7 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult {
pinger, err := probing.NewPinger(host) pinger, err := probing.NewPinger(host)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "ping setup: " + err.Error()}
} }
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = siteTimeout(site) pinger.Timeout = siteTimeout(site)
@@ -147,14 +147,14 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
} }
if pinger.Statistics().PacketsRecv == 0 { if pinger.Statistics().PacketsRecv == 0 {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
} }
stats := pinger.Statistics() stats := pinger.Statistics()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: stats.AvgRtt.Nanoseconds()}
} }
func runPortCheck(_ context.Context, site models.Site) CheckResult { func runPortCheck(_ context.Context, site models.Site) CheckResult {
@@ -170,10 +170,10 @@ func runPortCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)}
} }
_ = conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
} }
func runDNSCheck(_ context.Context, site models.Site) CheckResult { func runDNSCheck(_ context.Context, site models.Site) CheckResult {
@@ -221,12 +221,12 @@ func runDNSCheck(_ context.Context, site models.Site) CheckResult {
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
} }
if r.Rcode != dns.RcodeSuccess { if r.Rcode != dns.RcodeSuccess {
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]} return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]}
} }
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
} }
func siteTimeout(site models.Site) time.Duration { func siteTimeout(site models.Site) time.Duration {
+51 -52
View File
@@ -334,7 +334,7 @@ func (e *Engine) RecordHeartbeat(token string) bool {
} }
var ( var (
prevStatus string prevStatus models.Status
name string name string
alertID int alertID int
downSince time.Time downSince time.Time
@@ -346,12 +346,12 @@ func (e *Engine) RecordHeartbeat(token string) bool {
downSince = s.StatusChangedAt // captured before mutation = when it went down downSince = s.StatusChangedAt // captured before mutation = when it went down
s.LastCheck = time.Now() s.LastCheck = time.Now()
s.Status = "UP" s.Status = models.StatusUp
s.FailureCount = 0 s.FailureCount = 0
s.Latency = 0 s.Latency = 0
s.LastError = "" s.LastError = ""
s.LastSuccessAt = time.Now() s.LastSuccessAt = time.Now()
if prevStatus != "UP" { if prevStatus != models.StatusUp {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} }
}) })
@@ -360,13 +360,13 @@ func (e *Engine) RecordHeartbeat(token string) bool {
} }
switch prevStatus { switch prevStatus {
case "PENDING": case models.StatusPending:
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
case "LATE": case models.StatusLate:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
case "STALE": case models.StatusStale:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
case "DOWN": case models.StatusDown:
downDur := "" downDur := ""
if !downSince.IsZero() { if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince))) downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
@@ -375,8 +375,8 @@ func (e *Engine) RecordHeartbeat(token string) bool {
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur)) go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
} }
if prevStatus != "UP" && prevStatus != "PENDING" { if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: prevStatus, toStatus: "UP"}) e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
} }
return true return true
@@ -434,12 +434,12 @@ func (e *Engine) Start(ctx context.Context) {
e.mu.RUnlock() e.mu.RUnlock()
if !exists { if !exists {
e.mu.Lock() e.mu.Lock()
s.Status = "PENDING" s.Status = models.StatusPending
if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
if h.Statuses[len(h.Statuses)-1] { if h.Statuses[len(h.Statuses)-1] {
s.Status = "UP" s.Status = models.StatusUp
} else { } else {
s.Status = "DOWN" s.Status = models.StatusDown
} }
if len(h.Latencies) > 0 { if len(h.Latencies) > 0 {
s.Latency = h.Latencies[len(h.Latencies)-1] s.Latency = h.Latencies[len(h.Latencies)-1]
@@ -686,7 +686,7 @@ func (e *Engine) checkByID(ctx context.Context, id int) {
} }
func (e *Engine) checkPush(_ context.Context, site models.Site) { func (e *Engine) checkPush(_ context.Context, site models.Site) {
if site.Status == "PENDING" { if site.Status == models.StatusPending {
return return
} }
@@ -702,16 +702,16 @@ func (e *Engine) checkPush(_ context.Context, site models.Site) {
now := time.Now() now := time.Now()
if now.After(graceEnd) { if now.After(graceEnd) {
if site.Status != "DOWN" { if site.Status != models.StatusDown {
e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed") e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
} }
} else if now.After(staleMark) { } else if now.After(staleMark) {
if site.Status != "STALE" { if site.Status != models.StatusStale {
e.handleStatusChange(site, "STALE", 0, 0, "heartbeat stale") e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
} }
} else if now.After(overdue) { } else if now.After(overdue) {
if site.Status != "LATE" { if site.Status != models.StatusLate {
e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue") e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
} }
} }
} }
@@ -727,9 +727,10 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
inMaint := e.isInMaintenance(snap.ID) inMaint := e.isInMaintenance(snap.ID)
status := models.Status(rawStatus)
var ( var (
prev, next string prev, next models.Status
name, typ string name, typ string
alertID int alertID int
failCount, maxRetries int failCount, maxRetries int
@@ -745,7 +746,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
_, exists := e.applyState(snap.ID, func(s *models.Site) { _, exists := e.applyState(snap.ID, func(s *models.Site) {
// A non-UP result computed from a stale snapshot must not override a // A non-UP result computed from a stale snapshot must not override a
// heartbeat (or newer check) that landed while we were evaluating. // heartbeat (or newer check) that landed while we were evaluating.
if rawStatus != "UP" && s.LastCheck.After(snap.LastCheck) { if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
skipped = true skipped = true
return return
} }
@@ -764,24 +765,24 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
s.HasSSL = snap.HasSSL s.HasSSL = snap.HasSSL
s.CertExpiry = snap.CertExpiry s.CertExpiry = snap.CertExpiry
s.LastError = errorReason s.LastError = errorReason
if rawStatus == "UP" { if status == models.StatusUp {
s.LastSuccessAt = time.Now() s.LastSuccessAt = time.Now()
s.LastError = "" s.LastError = ""
} }
// Status + failure-count transition, based on the CURRENT live status. // Status + failure-count transition, based on the CURRENT live status.
if rawStatus == "UP" { if status == models.StatusUp {
s.FailureCount = 0 s.FailureCount = 0
s.Status = "UP" s.Status = models.StatusUp
} else { } else {
if s.FailureCount <= s.MaxRetries { if s.FailureCount <= s.MaxRetries {
s.FailureCount++ s.FailureCount++
} }
if s.FailureCount > s.MaxRetries { if s.FailureCount > s.MaxRetries {
if s.Status != rawStatus { if s.Status != status {
confirmedDown = true confirmedDown = true
} }
s.Status = rawStatus s.Status = status
s.FailureCount = s.MaxRetries + 1 s.FailureCount = s.MaxRetries + 1
} else { } else {
failedCheck = true failedCheck = true
@@ -789,16 +790,16 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
failCount = s.FailureCount failCount = s.FailureCount
if s.Status != prev && prev != "PENDING" { if s.Status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} else if s.StatusChangedAt.IsZero() && s.Status != "PENDING" { } else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
s.StatusChangedAt = time.Now() s.StatusChangedAt = time.Now()
} }
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold). // SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
if typ == "http" && s.CheckSSL && s.HasSSL { if typ == "http" && s.CheckSSL && s.HasSSL {
days := int(time.Until(s.CertExpiry).Hours() / 24) days := int(time.Until(s.CertExpiry).Hours() / 24)
if days <= s.ExpiryThreshold && !s.SentSSLWarning && rawStatus != "SSL EXP" { if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
sslWarnFire = true sslWarnFire = true
sslDays = days sslDays = days
s.SentSSLWarning = true s.SentSSLWarning = true
@@ -815,7 +816,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
return return
} }
e.recordCheck(snap.ID, latency, rawStatus == "UP") e.recordCheck(snap.ID, latency, status == models.StatusUp)
if confirmedDown { if confirmedDown {
if errorReason != "" { if errorReason != "" {
@@ -827,8 +828,8 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries)) e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
} }
if changed && prev != "PENDING" { if changed && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: prev, toStatus: next, reason: errorReason}) e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
} }
if sslWarnFire { if sslWarnFire {
@@ -839,13 +840,11 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
} }
} }
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } if prev == models.StatusUp && next == models.StatusLate {
if prev == "UP" && next == "LATE" {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name)) e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
} }
if !isBroken(prev) && isBroken(next) && next != "PENDING" { if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
if inMaint { if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name)) e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
} else { } else {
@@ -859,7 +858,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.triggerAlert(alertID, "🚨 ALERT", msg) e.triggerAlert(alertID, "🚨 ALERT", msg)
} }
} }
if isBroken(prev) && next == "UP" { if prev.IsBroken() && next == models.StatusUp {
downDur := "" downDur := ""
if !downSince.IsZero() { if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince))) downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
@@ -869,7 +868,7 @@ func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur)) e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
} }
} }
if prev == "LATE" && next == "UP" && !isBroken(prev) { if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name)) e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
} }
} }
@@ -991,12 +990,12 @@ func (e *Engine) GetDisplayStatus(site models.Site) string {
if e.isInMaintenance(site.ID) { if e.isInMaintenance(site.ID) {
return "MAINT" return "MAINT"
} }
return site.Status return string(site.Status)
} }
func (e *Engine) checkGroup(_ context.Context, site models.Site) { func (e *Engine) checkGroup(_ context.Context, site models.Site) {
e.mu.RLock() e.mu.RLock()
status := "UP" status := models.StatusUp
hasChildren := false hasChildren := false
for _, child := range e.liveState { for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" { if child.ParentID != site.ID || child.Type == "group" {
@@ -1006,20 +1005,20 @@ func (e *Engine) checkGroup(_ context.Context, site models.Site) {
if child.Paused || e.isInMaintenance(child.ID) { if child.Paused || e.isInMaintenance(child.ID) {
continue continue
} }
if child.Status == "DOWN" || child.Status == "SSL EXP" { if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
status = "DOWN" status = models.StatusDown
} else if child.Status == "STALE" && status != "DOWN" { } else if child.Status == models.StatusStale && status != models.StatusDown {
status = "STALE" status = models.StatusStale
} else if child.Status == "LATE" && status != "DOWN" && status != "STALE" { } else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
status = "LATE" status = models.StatusLate
} else if child.Status == "PENDING" && status != "DOWN" && status != "STALE" && status != "LATE" { } else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
status = "PENDING" status = models.StatusPending
} }
} }
e.mu.RUnlock() e.mu.RUnlock()
if !hasChildren { if !hasChildren {
status = "PENDING" status = models.StatusPending
} }
e.applyState(site.ID, func(s *models.Site) { e.applyState(site.ID, func(s *models.Site) {
@@ -1072,15 +1071,15 @@ func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, i
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy) aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
rawStatus := "UP" probeStatus := models.StatusUp
if !aggUp { if !aggUp {
rawStatus = "DOWN" probeStatus = models.StatusDown
} }
updatedSite := site updatedSite := site
updatedSite.Latency = time.Duration(avgLatency) updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now() updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency), errorReason) e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
} }
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult { func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
+6 -61
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store --- // --- Mock Store ---
@@ -19,6 +20,7 @@ type savedCheck struct {
} }
type mockStore struct { type mockStore struct {
storetest.BaseMock
mu sync.Mutex mu sync.Mutex
sites []models.Site sites []models.Site
alerts map[int]models.AlertConfig 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) 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) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) { func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -83,25 +51,6 @@ func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.Maint
} }
return windows, nil 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) { func (m *mockStore) GetAllAlerts(context.Context) ([]models.AlertConfig, error) {
m.mu.Lock() m.mu.Lock()
@@ -154,18 +103,14 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
return nil 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 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 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 --- // --- Helpers ---
func newTestEngine(ms *mockStore) *Engine { func newTestEngine(ms *mockStore) *Engine {
+9 -13
View File
@@ -16,14 +16,14 @@ type SLAReport struct {
MTBF time.Duration MTBF time.Duration
} }
func ComputeSLA(changes []models.StateChange, currentStatus string, window time.Duration) SLAReport { func ComputeSLA(changes []models.StateChange, currentStatus models.Status, window time.Duration) SLAReport {
now := time.Now() now := time.Now()
windowStart := now.Add(-window) windowStart := now.Add(-window)
report := SLAReport{Window: window} report := SLAReport{Window: window}
if len(changes) == 0 { if len(changes) == 0 {
if isDown(currentStatus) { if models.Status(currentStatus).IsBroken() {
report.UptimePct = 0 report.UptimePct = 0
report.Downtime = window report.Downtime = window
} else { } else {
@@ -40,7 +40,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
} }
// Determine status at window start: last transition before or at windowStart. // Determine status at window start: last transition before or at windowStart.
statusAtStart := "UP" statusAtStart := string(models.StatusUp)
for i := len(sorted) - 1; i >= 0; i-- { for i := len(sorted) - 1; i >= 0; i-- {
if !sorted[i].ChangedAt.After(windowStart) { if !sorted[i].ChangedAt.After(windowStart) {
statusAtStart = sorted[i].ToStatus statusAtStart = sorted[i].ToStatus
@@ -51,7 +51,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
var upTime, downTime time.Duration var upTime, downTime time.Duration
var outages []time.Duration var outages []time.Duration
cursor := windowStart cursor := windowStart
wasDown := isDown(statusAtStart) wasDown := models.Status(statusAtStart).IsBroken()
if wasDown { if wasDown {
report.OutageCount = 1 report.OutageCount = 1
@@ -77,7 +77,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
upTime += seg upTime += seg
} }
newDown := isDown(sc.ToStatus) newDown := models.Status(sc.ToStatus).IsBroken()
if !wasDown && newDown { if !wasDown && newDown {
report.OutageCount++ report.OutageCount++
outageStart = sc.ChangedAt outageStart = sc.ChangedAt
@@ -127,7 +127,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus string, window time.
return report return report
} }
func ComputeDailyBreakdown(changes []models.StateChange, currentStatus string, days int, now time.Time) []DayReport { func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.Status, days int, now time.Time) []DayReport {
reports := make([]DayReport, days) reports := make([]DayReport, days)
for i := 0; i < days; i++ { for i := 0; i < days; i++ {
@@ -159,10 +159,6 @@ type DayReport struct {
UptimePct float64 UptimePct float64
} }
func isDown(status string) bool {
return status == "DOWN" || status == "SSL EXP"
}
func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange { func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange {
var filtered []models.StateChange var filtered []models.StateChange
for _, sc := range changes { for _, sc := range changes {
@@ -180,7 +176,7 @@ func inferStatusAt(changes []models.StateChange, at time.Time) string {
return sc.ToStatus return sc.ToStatus
} }
} }
return "UP" return string(models.StatusUp)
} }
func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 { func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 {
@@ -193,7 +189,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
var upTime, downTime time.Duration var upTime, downTime time.Duration
cursor := start cursor := start
wasDown := isDown(statusAtStart) wasDown := models.Status(statusAtStart).IsBroken()
for _, sc := range sorted { for _, sc := range sorted {
if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) { if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) {
@@ -205,7 +201,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
} else { } else {
upTime += seg upTime += seg
} }
wasDown = isDown(sc.ToStatus) wasDown = models.Status(sc.ToStatus).IsBroken()
cursor = sc.ChangedAt cursor = sc.ChangedAt
} }
+13 -13
View File
@@ -137,24 +137,24 @@ func TestComputeDailyBreakdown(t *testing.T) {
} }
} }
func TestIsDown(t *testing.T) { func TestIsBroken(t *testing.T) {
if !isDown("DOWN") { if !models.StatusDown.IsBroken() {
t.Error("DOWN should be down") t.Error("DOWN should be broken")
} }
if !isDown("SSL EXP") { if !models.StatusSSLExp.IsBroken() {
t.Error("SSL EXP should be down") t.Error("SSL EXP should be broken")
} }
if isDown("UP") { if models.StatusUp.IsBroken() {
t.Error("UP should not be down") t.Error("UP should not be broken")
} }
if isDown("LATE") { if models.StatusLate.IsBroken() {
t.Error("LATE should not be down") t.Error("LATE should not be broken")
} }
if isDown("STALE") { if models.StatusStale.IsBroken() {
t.Error("STALE should not be down") t.Error("STALE should not be broken")
} }
if isDown("PENDING") { if models.StatusPending.IsBroken() {
t.Error("PENDING should not be down") t.Error("PENDING should not be broken")
} }
} }
+5 -5
View File
@@ -468,15 +468,15 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} }
public := make(map[int]statusSite, len(state)) public := make(map[int]statusSite, len(state))
for id, site := range state { for id, site := range state {
status := site.Status displayStatus := string(site.Status)
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
status = "MAINT" displayStatus = "MAINT"
} }
public[id] = statusSite{ public[id] = statusSite{
Name: site.Name, Name: site.Name,
Type: site.Type, Type: site.Type,
URL: site.URL, URL: site.URL,
Status: status, Status: displayStatus,
Paused: site.Paused, Paused: site.Paused,
LastCheck: site.LastCheck, LastCheck: site.LastCheck,
Latency: site.Latency, Latency: site.Latency,
@@ -569,10 +569,10 @@ func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine)
sort.Slice(sites, func(i, j int) bool { sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status { if sites[i].Status != sites[j].Status {
if sites[i].Status == "DOWN" { if sites[i].Status == models.StatusDown {
return true return true
} }
if sites[j].Status == "DOWN" { if sites[j].Status == models.StatusDown {
return false return false
} }
} }
+3 -75
View File
@@ -13,11 +13,13 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store --- // --- Mock Store ---
type mockStore struct { type mockStore struct {
storetest.BaseMock
mu sync.Mutex mu sync.Mutex
sites []models.Site sites []models.Site
alerts []models.AlertConfig 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) 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) { func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
return m.alerts, nil 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) { func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) {
return models.Backup{ return models.Backup{
+7 -1
View File
@@ -5,10 +5,16 @@ import (
"strconv" "strconv"
) )
type Migration struct {
Version int
SQL string
}
type Dialect interface { type Dialect interface {
DriverName() string DriverName() string
CreateTablesSQL() []string CreateTablesSQL() []string
MigrationsSQL() []string Migrations() []Migration
BaselineVersion() int
BoolFalse() string BoolFalse() string
ResetSequenceOnEmpty(db *sql.DB, table string) ResetSequenceOnEmpty(db *sql.DB, table string)
ImportWipe(tx *sql.Tx) ImportWipe(tx *sql.Tx)
+30 -27
View File
@@ -13,8 +13,9 @@ func NewPostgresStore(connStr string) (*SQLStore, error) {
return NewSQLStore("postgres", connStr, &PostgresDialect{}) return NewSQLStore("postgres", connStr, &PostgresDialect{})
} }
func (d *PostgresDialect) DriverName() string { return "postgres" } func (d *PostgresDialect) DriverName() string { return "postgres" }
func (d *PostgresDialect) BoolFalse() string { return "FALSE" } func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
func (d *PostgresDialect) BaselineVersion() int { return 21 }
func (d *PostgresDialect) CreateTablesSQL() []string { func (d *PostgresDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -32,7 +33,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', 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 ( `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -42,7 +44,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
site_id INTEGER NOT NULL, latency_ns BIGINT, 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 INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
@@ -92,29 +95,29 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
} }
} }
func (d *PostgresDialect) MigrationsSQL() []string { func (d *PostgresDialect) Migrations() []Migration {
return []string{ return []Migration{
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''", {1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0", {2, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0", {3, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'", {4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''", {5, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0", {6, "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'", {7, "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 ''", {8, "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 ''", {9, "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", {10, "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", {11, "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 ''", {12, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''", {13, "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'", {14, "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'", {15, "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'", {16, "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'", {17, "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'", {18, "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'", {19, "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'", {20, "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'", {21, "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'"},
} }
} }
+22 -19
View File
@@ -28,8 +28,9 @@ func NewSQLiteStore(path string) (*SQLStore, error) {
return s, nil return s, nil
} }
func (d *SQLiteDialect) DriverName() string { return "sqlite" } func (d *SQLiteDialect) DriverName() string { return "sqlite" }
func (d *SQLiteDialect) BoolFalse() string { return "0" } func (d *SQLiteDialect) BoolFalse() string { return "0" }
func (d *SQLiteDialect) BaselineVersion() int { return 13 }
func (d *SQLiteDialect) CreateTablesSQL() []string { func (d *SQLiteDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -47,7 +48,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', 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 ( `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -57,7 +59,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL, latency_ns INTEGER, 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 INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
@@ -107,21 +110,21 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
} }
} }
func (d *SQLiteDialect) MigrationsSQL() []string { func (d *SQLiteDialect) Migrations() []Migration {
return []string{ return []Migration{
"ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''", {1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0", {2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0", {3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'", {4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"},
"ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''", {5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0", {6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'", {7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"},
"ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''", {8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", {9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", {10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"},
"ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", {11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"},
"ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", {12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"},
"ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''", {13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"},
} }
} }
+32 -10
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -80,13 +79,34 @@ func (s *SQLStore) Init(ctx context.Context) error {
return err return err
} }
} }
for _, m := range s.dialect.MigrationsSQL() {
if _, err := s.db.ExecContext(ctx, m); err != nil { if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (
errMsg := err.Error() version INTEGER PRIMARY KEY,
if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") { applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
continue )`); err != nil {
} return fmt.Errorf("create schema_version: %w", err)
return fmt.Errorf("migration failed: %w", err) }
var current int
_ = s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&current) //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 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 { 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 := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
if err != nil { return err
}
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
return err return err
} }
s.dialect.ResetSequenceOnEmpty(s.db, "alerts") s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
+274
View File
@@ -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
}
+9 -9
View File
@@ -143,16 +143,16 @@ func (m Model) fmtRetries(site models.Site) string {
dispCount = site.MaxRetries dispCount = site.MaxRetries
} }
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
if site.Status == "DOWN" { if site.Status == models.StatusDown {
return m.st.dangerStyle.Render(s) return m.st.dangerStyle.Render(s)
} }
if site.Status == "UP" && site.FailureCount > 0 { if site.Status == models.StatusUp && site.FailureCount > 0 {
return m.st.warnStyle.Render(s) return m.st.warnStyle.Render(s)
} }
return s return s
} }
func (m Model) fmtStatus(status string, paused bool, inMaint bool) string { func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused { if paused {
return m.st.warnStyle.Render("◇ PAUSED") return m.st.warnStyle.Render("◇ PAUSED")
} }
@@ -160,18 +160,18 @@ func (m Model) fmtStatus(status string, paused bool, inMaint bool) string {
return m.st.maintStyle.Render("◼ MAINT") return m.st.maintStyle.Render("◼ MAINT")
} }
switch status { switch status {
case "DOWN": case models.StatusDown:
return m.st.dangerStyle.Render("▼ DOWN") return m.st.dangerStyle.Render("▼ DOWN")
case "SSL EXP": case models.StatusSSLExp:
return m.st.dangerStyle.Render("▼ SSL EXP") return m.st.dangerStyle.Render("▼ SSL EXP")
case "LATE": case models.StatusLate:
return m.st.warnStyle.Render("◆ LATE") return m.st.warnStyle.Render("◆ LATE")
case "STALE": case models.StatusStale:
return m.st.staleStyle.Render("◆ STALE") return m.st.staleStyle.Render("◆ STALE")
case "PENDING": case models.StatusPending:
return m.st.subtleStyle.Render("○ PENDING") return m.st.subtleStyle.Render("○ PENDING")
default: default:
return m.st.specialStyle.Render("▲ " + status) return m.st.specialStyle.Render("▲ " + string(status))
} }
} }
+9 -9
View File
@@ -56,19 +56,19 @@ func TestSiteOrder(t *testing.T) {
func TestFmtStatus(t *testing.T) { func TestFmtStatus(t *testing.T) {
tests := []struct { tests := []struct {
status string status models.Status
paused bool paused bool
inMaint bool inMaint bool
wantSub string wantSub string
}{ }{
{"DOWN", false, false, "▼ DOWN"}, {models.StatusDown, false, false, "▼ DOWN"},
{"UP", false, false, "▲ UP"}, {models.StatusUp, false, false, "▲ UP"},
{"SSL EXP", false, false, "▼ SSL EXP"}, {models.StatusSSLExp, false, false, "▼ SSL EXP"},
{"LATE", false, false, "◆ LATE"}, {models.StatusLate, false, false, "◆ LATE"},
{"STALE", false, false, "◆ STALE"}, {models.StatusStale, false, false, "◆ STALE"},
{"PENDING", false, false, "○ PENDING"}, {models.StatusPending, false, false, "○ PENDING"},
{"DOWN", true, false, "◇ PAUSED"}, {models.StatusDown, true, false, "◇ PAUSED"},
{"DOWN", false, true, "◼ MAINT"}, {models.StatusDown, false, true, "◼ MAINT"},
} }
for _, tt := range tests { for _, tt := range tests {
got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint) got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint)
+1 -1
View File
@@ -240,7 +240,7 @@ func (m Model) viewSitesTab() string {
name = limitStr(name, nameW-2) name = limitStr(name, nameW-2)
} }
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
nameLen := len([]rune(name)) nameLen := len([]rune(name))
errSpace := nameW - nameLen - 3 errSpace := nameW - nameLen - 3
if errSpace > 10 { if errSpace > 10 {
+1 -1
View File
@@ -455,7 +455,7 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
} }
period := slaPeriods[msg.periodIdx] period := slaPeriods[msg.periodIdx]
var currentStatus string var currentStatus models.Status
for _, s := range m.sites { for _, s := range m.sites {
if s.ID == msg.siteID { if s.ID == msg.siteID {
currentStatus = s.Status currentStatus = s.Status
+4 -86
View File
@@ -8,6 +8,7 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone" zone "github.com/lrstanley/bubblezone"
) )
@@ -15,13 +16,14 @@ import (
// --- minimal Store mock for TUI data-flow tests --- // --- minimal Store mock for TUI data-flow tests ---
type tuiMockStore struct { type tuiMockStore struct {
storetest.BaseMock
alerts []models.AlertConfig alerts []models.AlertConfig
users []models.User users []models.User
nodes []models.ProbeNode nodes []models.ProbeNode
maint []models.MaintenanceWindow maint []models.MaintenanceWindow
stateChanges []models.StateChange stateChanges []models.StateChange
stateChangeCalls int // counts GetStateChanges hits (to prove View does no IO) stateChangeCalls int
deleteSiteCalls int // counts DeleteSite hits (to prove writes run in Cmds) deleteSiteCalls int
} }
func (m *tuiMockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { 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) { func (m *tuiMockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) {
return m.maint, nil 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 { func (m *tuiMockStore) DeleteSite(_ context.Context, _ int) error {
m.deleteSiteCalls++ m.deleteSiteCalls++
return nil 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 { func newTestModel(ms *tuiMockStore) Model {
return Model{ return Model{
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -16,7 +17,7 @@ func sinApprox(x float64) float64 {
func (m Model) pulseIndicator() string { func (m Model) pulseIndicator() string {
hasDown := false hasDown := false
for _, s := range m.sites { for _, s := range m.sites {
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == models.StatusDown || s.Status == models.StatusSSLExp) {
hasDown = true hasDown = true
break break
} }
@@ -127,9 +128,9 @@ func (m Model) computeStats() dashboardStats {
continue continue
} }
switch site.Status { switch site.Status {
case "DOWN", "SSL EXP": case models.StatusDown, models.StatusSSLExp:
s.downCount++ s.downCount++
case "LATE": case models.StatusLate:
s.lateCount++ s.lateCount++
} }
} }
+4 -4
View File
@@ -45,7 +45,7 @@ func (m Model) viewDetailPanel() string {
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errWidth := m.termWidth - chromePadH - 19 errWidth := m.termWidth - chromePadH - 19
if errWidth < 30 { if errWidth < 30 {
errWidth = 30 errWidth = 30
@@ -58,7 +58,7 @@ func (m Model) viewDetailPanel() string {
row("HTTP Code", strconv.Itoa(site.StatusCode)) row("HTTP Code", strconv.Itoa(site.StatusCode))
} }
if (site.Status == "DOWN" || site.Status == "SSL EXP") && site.LastError != "" { if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 { if len(chain) > 0 {
b.WriteString("\n") b.WriteString("\n")
@@ -189,7 +189,7 @@ func (m Model) viewDetailPanel() string {
for i, sc := range stateChanges { for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt)) ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == "UP" { if sc.ToStatus == string(models.StatusUp) {
arrow += m.st.specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
} else { } else {
arrow += m.st.dangerStyle.Render(sc.ToStatus) arrow += m.st.dangerStyle.Render(sc.ToStatus)
@@ -198,7 +198,7 @@ func (m Model) viewDetailPanel() string {
if dur := computeOutageDuration(stateChanges, i); dur > 0 { if dur := computeOutageDuration(stateChanges, i); dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur)) line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
} }
if sc.ErrorReason != "" && sc.ToStatus != "UP" { if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
line += " " + m.st.dangerStyle.Render(sc.ErrorReason) line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
+6 -6
View File
@@ -17,14 +17,14 @@ type historyStats struct {
func computeOutageDuration(changes []models.StateChange, idx int) time.Duration { func computeOutageDuration(changes []models.StateChange, idx int) time.Duration {
sc := changes[idx] sc := changes[idx]
if sc.ToStatus != "UP" { if sc.ToStatus != string(models.StatusUp) {
return 0 return 0
} }
if idx+1 >= len(changes) { if idx+1 >= len(changes) {
return 0 return 0
} }
prev := changes[idx+1] prev := changes[idx+1]
if prev.ToStatus == "UP" { if prev.ToStatus == string(models.StatusUp) {
return 0 return 0
} }
dur := sc.ChangedAt.Sub(prev.ChangedAt) dur := sc.ChangedAt.Sub(prev.ChangedAt)
@@ -122,11 +122,11 @@ func (m Model) buildHistoryContent() string {
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus { switch sc.ToStatus {
case "UP": case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
case "LATE": case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus) arrow += m.st.warnStyle.Render(sc.ToStatus)
case "STALE": case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus) arrow += m.st.staleStyle.Render(sc.ToStatus)
default: default:
arrow += m.st.dangerStyle.Render(sc.ToStatus) arrow += m.st.dangerStyle.Render(sc.ToStatus)
@@ -138,7 +138,7 @@ func (m Model) buildHistoryContent() string {
} }
reason := "" reason := ""
if sc.ErrorReason != "" && sc.ToStatus != "UP" { if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
} }