diff --git a/cmd/uptop/main.go b/cmd/uptop/main.go index 5816105..900cb03 100644 --- a/cmd/uptop/main.go +++ b/cmd/uptop/main.go @@ -474,7 +474,7 @@ func seedDemoData(s store.Store) { alertID = alerts[0].ID } - demoSites := []models.Site{ + demoSites := []models.SiteConfig{ {Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2}, {Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}, {Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}, diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index bb36f4a..d5112e3 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -203,7 +203,7 @@ func TestProbeRegister_Failure(t *testing.T) { func TestProbeFetchAssignments_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string][]models.Site{ - "sites": {{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}}, + "sites": {{SiteConfig: models.SiteConfig{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}}}, }) })) defer srv.Close() @@ -240,8 +240,8 @@ func TestProbeExecuteChecks(t *testing.T) { defer srv.Close() sites := []models.Site{ - {ID: 1, Type: "http", URL: srv.URL}, - {ID: 2, Type: "http", URL: srv.URL}, + {SiteConfig: models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}}, + {SiteConfig: models.SiteConfig{ID: 2, Type: "http", URL: srv.URL}}, } strict := &http.Client{} @@ -277,7 +277,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) { var sites []models.Site for i := 0; i < 20; i++ { - sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL}) + sites = append(sites, models.Site{SiteConfig: models.SiteConfig{ID: i + 1, Type: "http", URL: srv.URL}}) } results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true) diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go index 93dbf66..fa11be8 100644 --- a/internal/cluster/probe.go +++ b/internal/cluster/probe.go @@ -152,7 +152,7 @@ loop: defer wg.Done() defer func() { <-sem }() - cr := monitor.RunCheck(ctx, s, strict, insecure, false, allowPrivate) + cr := monitor.RunCheck(ctx, s.SiteConfig, strict, insecure, false, allowPrivate) mu.Lock() results = append(results, probeResultItem{ SiteID: s.ID, diff --git a/internal/config/apply.go b/internal/config/apply.go index e5a9bfa..e66247b 100644 --- a/internal/config/apply.go +++ b/internal/config/apply.go @@ -42,7 +42,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang existingAlertsByName[a.Name] = a } - existingSitesByName := make(map[string]models.Site, len(existingSites)) + existingSitesByName := make(map[string]models.SiteConfig, len(existingSites)) for _, s := range existingSites { existingSitesByName[s.Name] = s } @@ -181,7 +181,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang return changes, nil } -func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) { +func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.SiteConfig, parentID int, dryRun bool) ([]Change, error) { alertID, err := resolveAlertID(alertMap, m.Alert) if err != nil { return nil, fmt.Errorf("monitor %q: %w", m.Name, err) @@ -222,8 +222,8 @@ func resolveAlertID(alertMap map[string]int, name string) (int, error) { return id, nil } -func monitorToSite(m Monitor, alertID, parentID int) models.Site { - s := models.Site{ +func monitorToSite(m Monitor, alertID, parentID int) models.SiteConfig { + s := models.SiteConfig{ Name: m.Name, Type: m.Type, URL: m.URL, @@ -269,7 +269,7 @@ func collectMonitorNames(monitors []Monitor, names map[string]bool) { } } -func normalizeSite(s models.Site) models.Site { +func normalizeSite(s models.SiteConfig) models.SiteConfig { if s.Method == "" { s.Method = "GET" } @@ -293,7 +293,7 @@ func diffAlert(existing models.AlertConfig, desired Alert) string { return strings.Join(diffs, ", ") } -func diffSite(existing, desired models.Site) string { +func diffSite(existing, desired models.SiteConfig) string { var diffs []string if existing.URL != desired.URL { diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL)) diff --git a/internal/config/apply_test.go b/internal/config/apply_test.go index 6a661b2..635adc2 100644 --- a/internal/config/apply_test.go +++ b/internal/config/apply_test.go @@ -114,8 +114,8 @@ func TestApplyUpdate(t *testing.T) { func TestApplyPrune(t *testing.T) { s := newTestStore(t) - s.AddSite(context.Background(), models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) - s.AddSite(context.Background(), models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(context.Background(), models.SiteConfig{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(context.Background(), models.SiteConfig{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) f := &File{ Monitors: []Monitor{ @@ -191,7 +191,7 @@ func TestApplyGroupHierarchy(t *testing.T) { } sites, _ := s.GetSites(context.Background()) - var group models.Site + var group models.SiteConfig for _, s := range sites { if s.Type == "group" { group = s diff --git a/internal/config/export.go b/internal/config/export.go index 6902e84..5d545be 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -34,9 +34,9 @@ func Export(ctx context.Context, s store.Store) (*File, error) { }) } - groups := make(map[int]models.Site) - children := make(map[int][]models.Site) - var topLevel []models.Site + groups := make(map[int]models.SiteConfig) + children := make(map[int][]models.SiteConfig) + var topLevel []models.SiteConfig for _, s := range dbSites { switch { @@ -76,7 +76,7 @@ func Export(ctx context.Context, s store.Store) (*File, error) { return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil } -func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor { +func siteToMonitor(s models.SiteConfig, alertIDToName map[int]string) Monitor { m := Monitor{ Name: s.Name, Type: s.Type, diff --git a/internal/config/export_test.go b/internal/config/export_test.go index da2ce1c..ca1646e 100644 --- a/internal/config/export_test.go +++ b/internal/config/export_test.go @@ -22,7 +22,7 @@ func TestExportAlertNames(t *testing.T) { s := newTestStore(t) s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) alerts, _ := s.GetAllAlerts(context.Background()) - s.AddSite(context.Background(), models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) f, err := Export(context.Background(), s) if err != nil { @@ -39,9 +39,9 @@ func TestExportAlertNames(t *testing.T) { func TestExportGroupHierarchy(t *testing.T) { s := newTestStore(t) - groupID, _ := s.AddSiteReturningID(context.Background(), models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) - s.AddSite(context.Background(), models.Site{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) - s.AddSite(context.Background(), models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + groupID, _ := s.AddSiteReturningID(context.Background(), models.SiteConfig{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(context.Background(), models.SiteConfig{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(context.Background(), models.SiteConfig{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) f, err := Export(context.Background(), s) if err != nil { @@ -72,7 +72,7 @@ func TestExportGroupHierarchy(t *testing.T) { func TestExportOmitsDefaults(t *testing.T) { s := newTestStore(t) - s.AddSite(context.Background(), models.Site{ + s.AddSite(context.Background(), models.SiteConfig{ Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7, }) @@ -98,8 +98,8 @@ func TestExportRoundTrip(t *testing.T) { s1 := newTestStore(t) s1.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) alerts, _ := s1.GetAllAlerts(context.Background()) - s1.AddSite(context.Background(), models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) - s1.AddSite(context.Background(), models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s1.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s1.AddSite(context.Background(), models.SiteConfig{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) exported, err := Export(context.Background(), s1) if err != nil { diff --git a/internal/importer/kuma.go b/internal/importer/kuma.go index b8043b9..f17022d 100644 --- a/internal/importer/kuma.go +++ b/internal/importer/kuma.go @@ -3,9 +3,10 @@ package importer import ( "encoding/json" "fmt" - "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "os" "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) type KumaBackup struct { @@ -80,7 +81,7 @@ func ConvertKuma(kb *KumaBackup) models.Backup { } } - var sites []models.Site + var sites []models.SiteConfig for _, m := range kb.MonitorList { site := convertKumaMonitor(m, kumaToUpkeepAlert) sites = append(sites, site) @@ -132,8 +133,8 @@ func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConf return result } -func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site { - site := models.Site{ +func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig { + site := models.SiteConfig{ ID: m.ID, Name: m.Name, Description: m.Description, diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index ccf3d0f..e8be700 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -15,16 +15,16 @@ import ( type mockStore struct { storetest.BaseMock - sites []models.Site + sites []models.SiteConfig } -func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { +func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ - sites: []models.Site{ + sites: []models.SiteConfig{ {ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30}, {ID: 2, Name: "DNS Check", Type: "dns", Interval: 60}, }, diff --git a/internal/models/models.go b/internal/models/models.go index 668b9dd..b8983a4 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -2,7 +2,7 @@ package models import "time" -type Site struct { +type SiteConfig struct { ID int Name string URL string @@ -26,7 +26,9 @@ type Site struct { IgnoreTLS bool Paused bool Regions string +} +type SiteState struct { FailureCount int Status Status StatusCode int @@ -40,6 +42,11 @@ type Site struct { LastSuccessAt time.Time } +type Site struct { + SiteConfig + SiteState +} + type StateChange struct { ID int SiteID int @@ -103,7 +110,7 @@ type MaintenanceWindow struct { } type Backup struct { - Sites []Site `json:"sites"` + Sites []SiteConfig `json:"sites"` Alerts []AlertConfig `json:"alerts"` Users []User `json:"users"` MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"` diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go index 13abce2..cbb7fdb 100644 --- a/internal/monitor/checker.go +++ b/internal/monitor/checker.go @@ -35,7 +35,7 @@ type CheckResult struct { ErrorReason string } -func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult { +func RunCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult { private := len(allowPrivate) > 0 && allowPrivate[0] if site.Type != "http" && site.Type != "dns" && !private { @@ -68,7 +68,7 @@ func RunCheck(ctx context.Context, site models.Site, strict, insecure *http.Clie } } -func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { +func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure bool) CheckResult { method := site.Method if method == "" { method = "GET" @@ -128,7 +128,7 @@ func runHTTPCheck(ctx context.Context, site models.Site, strict, insecure *http. return result } -func runPingCheck(_ context.Context, site models.Site) CheckResult { +func runPingCheck(_ context.Context, site models.SiteConfig) CheckResult { host := site.Hostname if host == "" { host = site.URL @@ -157,7 +157,7 @@ func runPingCheck(_ context.Context, site models.Site) CheckResult { 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.SiteConfig) CheckResult { host := site.Hostname if host == "" { host = site.URL @@ -176,7 +176,7 @@ func runPortCheck(_ context.Context, site models.Site) CheckResult { 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.SiteConfig) CheckResult { host := site.Hostname if host == "" { host = site.URL @@ -229,7 +229,7 @@ func runDNSCheck(_ context.Context, site models.Site) CheckResult { return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()} } -func siteTimeout(site models.Site) time.Duration { +func siteTimeout(site models.SiteConfig) time.Duration { if site.Timeout > 0 { return time.Duration(site.Timeout) * time.Second } diff --git a/internal/monitor/checker_test.go b/internal/monitor/checker_test.go index c2cf15d..d0bf232 100644 --- a/internal/monitor/checker_test.go +++ b/internal/monitor/checker_test.go @@ -19,7 +19,7 @@ func TestRunCheck_HTTP_Success(t *testing.T) { })) defer srv.Close() - site := models.Site{ID: 1, Type: "http", URL: srv.URL} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false) if result.Status != "UP" { @@ -39,7 +39,7 @@ func TestRunCheck_HTTP_ServerError(t *testing.T) { })) defer srv.Close() - site := models.Site{ID: 1, Type: "http", URL: srv.URL} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false) if result.Status != "DOWN" { @@ -60,7 +60,7 @@ func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) { return http.ErrUseLastResponse }} - site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"} result := RunCheck(context.Background(), site, client, client, false) if result.Status != "UP" { @@ -76,7 +76,7 @@ func TestRunCheck_HTTP_MethodRespected(t *testing.T) { })) defer srv.Close() - site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"} RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false) if receivedMethod != "HEAD" { @@ -91,7 +91,7 @@ func TestRunCheck_HTTP_Timeout(t *testing.T) { })) defer srv.Close() - site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Timeout: 1} result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false) if result.Status != "DOWN" { @@ -109,7 +109,7 @@ func TestRunCheck_HTTP_SSLFields(t *testing.T) { Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, } - site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true} + site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true} result := RunCheck(context.Background(), site, http.DefaultClient, insecureClient, false) if result.Status != "UP" { @@ -133,7 +133,7 @@ func TestRunCheck_Port_Open(t *testing.T) { _, portStr, _ := net.SplitHostPort(ln.Addr().String()) port, _ := strconv.Atoi(portStr) - site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} + site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} result := RunCheck(context.Background(), site, nil, nil, false, true) if result.Status != "UP" { @@ -153,7 +153,7 @@ func TestRunCheck_Port_Closed(t *testing.T) { port, _ := strconv.Atoi(portStr) ln.Close() - site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} + site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} result := RunCheck(context.Background(), site, nil, nil, false, true) if result.Status != "DOWN" { @@ -171,7 +171,7 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) { _, portStr, _ := net.SplitHostPort(ln.Addr().String()) port, _ := strconv.Atoi(portStr) - site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} + site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} result := RunCheck(context.Background(), site, nil, nil, false) if result.Status != "DOWN" { @@ -180,7 +180,7 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) { } func TestRunCheck_UnknownType(t *testing.T) { - site := models.Site{ID: 1, Type: "invalid"} + site := models.SiteConfig{ID: 1, Type: "invalid"} result := RunCheck(context.Background(), site, nil, nil, false) if result.Status != "DOWN" { @@ -214,10 +214,10 @@ func TestIsCodeAccepted(t *testing.T) { } func TestSiteTimeout(t *testing.T) { - if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second { + if got := siteTimeout(models.SiteConfig{Timeout: 0}); got != 5*time.Second { t.Errorf("expected 5s default, got %v", got) } - if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second { + if got := siteTimeout(models.SiteConfig{Timeout: 10}); got != 10*time.Second { t.Errorf("expected 10s, got %v", got) } } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index ff7cca3..de3ac27 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -418,7 +418,7 @@ func (e *Engine) Start(ctx context.Context) { e.refreshMaintenanceCache(ctx) - sites, err := e.db.GetSites(ctx) + configs, err := e.db.GetSites(ctx) if err != nil { e.AddLog(fmt.Sprintf("Failed to load sites: %v", err)) select { @@ -428,31 +428,31 @@ func (e *Engine) Start(ctx context.Context) { } continue } - for _, s := range sites { + for _, cfg := range configs { e.mu.RLock() - _, exists := e.liveState[s.ID] + _, exists := e.liveState[cfg.ID] e.mu.RUnlock() if !exists { e.mu.Lock() - s.Status = models.StatusPending - if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { + site := models.Site{SiteConfig: cfg, SiteState: models.SiteState{Status: models.StatusPending}} + if h, ok := e.GetHistory(cfg.ID); ok && len(h.Statuses) > 0 { if h.Statuses[len(h.Statuses)-1] { - s.Status = models.StatusUp + site.Status = models.StatusUp } else { - s.Status = models.StatusDown + site.Status = models.StatusDown } if len(h.Latencies) > 0 { - s.Latency = h.Latencies[len(h.Latencies)-1] + site.Latency = h.Latencies[len(h.Latencies)-1] } } - e.liveState[s.ID] = s - e.addToTokenIndex(s) + e.liveState[cfg.ID] = site + e.addToTokenIndex(site) e.mu.Unlock() e.checkerWG.Add(1) go func(id int) { defer e.checkerWG.Done() e.monitorRoutine(ctx, id) - }(s.ID) + }(cfg.ID) } } @@ -498,27 +498,17 @@ func (e *Engine) pruneMaintenanceWindows(ctx context.Context) { } } -func (e *Engine) UpdateSiteConfig(site models.Site) { +func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) { e.mu.Lock() - if existing, ok := e.liveState[site.ID]; ok { - e.removeFromTokenIndex(site.ID) - site.Status = existing.Status - site.StatusCode = existing.StatusCode - site.Latency = existing.Latency - site.CertExpiry = existing.CertExpiry - site.HasSSL = existing.HasSSL - site.LastCheck = existing.LastCheck - site.SentSSLWarning = existing.SentSSLWarning - site.FailureCount = existing.FailureCount - site.LastError = existing.LastError - site.StatusChangedAt = existing.StatusChangedAt - site.LastSuccessAt = existing.LastSuccessAt - e.liveState[site.ID] = site - e.addToTokenIndex(site) + if existing, ok := e.liveState[cfg.ID]; ok { + e.removeFromTokenIndex(cfg.ID) + existing.SiteConfig = cfg + e.liveState[cfg.ID] = existing + e.addToTokenIndex(existing) } e.mu.Unlock() - e.signalRecheck(site.ID) + e.signalRecheck(cfg.ID) } func (e *Engine) getRecheckChan(id int) chan struct{} { @@ -675,7 +665,7 @@ func (e *Engine) checkByID(ctx context.Context, id int) { case "group": e.checkGroup(ctx, site) default: - result := RunCheck(ctx, site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets) + result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets) updatedSite := site updatedSite.HasSSL = result.HasSSL updatedSite.CertExpiry = result.CertExpiry diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 28f4ae0..45b8925 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -22,7 +22,7 @@ type savedCheck struct { type mockStore struct { storetest.BaseMock mu sync.Mutex - sites []models.Site + sites []models.SiteConfig alerts map[int]models.AlertConfig maintenance map[int]bool logs []string @@ -40,7 +40,7 @@ func newMockStore() *mockStore { } } -func (m *mockStore) GetSites(context.Context) ([]models.Site, error) { return m.sites, nil } +func (m *mockStore) GetSites(context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) { m.mu.Lock() @@ -148,7 +148,10 @@ func (m *mockStore) getAlertCallsSnapshot() []int { func TestHandleStatusChange_PendingToUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "PENDING", MaxRetries: 3, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1}, + SiteState: models.SiteState{Status: "PENDING"}, + } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "") @@ -169,7 +172,10 @@ func TestHandleStatusChange_PendingToUp(t *testing.T) { func TestHandleStatusChange_UpIncrementFailure(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 3, FailureCount: 0} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3}, + SiteState: models.SiteState{Status: "UP", FailureCount: 0}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 500, 0, "test error") @@ -187,7 +193,10 @@ func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 2, FailureCount: 2, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1}, + SiteState: models.SiteState{Status: "UP", FailureCount: 2}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 500, 0, "test error") @@ -210,7 +219,10 @@ func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, FailureCount: 0, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1}, + SiteState: models.SiteState{Status: "UP", FailureCount: 0}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0, "test error") @@ -229,7 +241,10 @@ func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "DOWN", FailureCount: 4, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1}, + SiteState: models.SiteState{Status: "DOWN", FailureCount: 4}, + } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "") @@ -250,7 +265,10 @@ func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) { func TestHandleStatusChange_DownStaysDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "DOWN", MaxRetries: 2, FailureCount: 3} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2}, + SiteState: models.SiteState{Status: "DOWN", FailureCount: 3}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0, "test error") @@ -269,7 +287,10 @@ func TestHandleStatusChange_SSLExpired(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired") @@ -289,7 +310,10 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) { ms.maintenance[1] = true ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.refreshMaintenanceCache(context.Background()) @@ -321,7 +345,10 @@ func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) { ms.maintenance[1] = true ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "DOWN", AlertID: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1}, + SiteState: models.SiteState{Status: "DOWN"}, + } injectSite(e, site) e.refreshMaintenanceCache(context.Background()) @@ -342,10 +369,8 @@ func TestHandleStatusChange_SSLWarning(t *testing.T) { ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "test", Status: "UP", Type: "http", - CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, - SentSSLWarning: false, AlertID: 1, - CertExpiry: time.Now().Add(15 * 24 * time.Hour), + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, + SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, } injectSite(e, site) @@ -365,10 +390,8 @@ func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "test", Status: "UP", Type: "http", - CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, - SentSSLWarning: true, AlertID: 1, - CertExpiry: time.Now().Add(15 * 24 * time.Hour), + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, + SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, } injectSite(e, site) @@ -384,10 +407,8 @@ func TestHandleStatusChange_SSLWarningReset(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "test", Status: "UP", Type: "http", - CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, - SentSSLWarning: true, - CertExpiry: time.Now().Add(60 * 24 * time.Hour), + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30}, + SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)}, } injectSite(e, site) @@ -405,10 +426,8 @@ func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) { ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "test", Status: "UP", Type: "http", - CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, - SentSSLWarning: false, AlertID: 1, - CertExpiry: time.Now().Add(15 * 24 * time.Hour), + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, + SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, } injectSite(e, site) e.refreshMaintenanceCache(context.Background()) @@ -428,7 +447,10 @@ func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) { func TestHandleStatusChange_InactiveEngine(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.SetActive(false) @@ -445,7 +467,10 @@ func TestHandleStatusChange_InactiveEngine(t *testing.T) { func TestRecordHeartbeat_ValidToken(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) if !e.RecordHeartbeat("abc123") { @@ -465,7 +490,10 @@ func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "DOWN", AlertID: 1, FailureCount: 3} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1}, + SiteState: models.SiteState{Status: "DOWN", FailureCount: 3}, + } injectSite(e, site) if !e.RecordHeartbeat("abc123") { @@ -497,7 +525,10 @@ func TestRecordHeartbeat_UnknownToken(t *testing.T) { func TestRecordHeartbeat_InactiveEngine(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Type: "push", Token: "abc123", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.SetActive(false) @@ -512,9 +543,8 @@ func TestCheckPush_DeadlineMissed(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "push", Type: "push", Status: "UP", - Interval: 10, MaxRetries: 0, - LastCheck: time.Now().Add(-120 * time.Second), + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0}, + SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)}, } injectSite(e, site) @@ -530,9 +560,8 @@ func TestCheckPush_OverdueBecomesLate(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "push", Type: "push", Status: "UP", - Interval: 300, - LastCheck: time.Now().Add(-310 * time.Second), + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300}, + SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)}, } injectSite(e, site) @@ -550,9 +579,8 @@ func TestCheckPush_OverdueBecomesStale(t *testing.T) { // interval=300, grace=150 (300/2), staleMark=overdue+75 // at 380s: past staleMark(375) but before graceEnd(450) site := models.Site{ - ID: 1, Name: "push", Type: "push", Status: "UP", - Interval: 300, - LastCheck: time.Now().Add(-380 * time.Second), + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300}, + SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-380 * time.Second)}, } injectSite(e, site) @@ -568,8 +596,8 @@ func TestCheckPush_WithinDeadline(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "push", Type: "push", Status: "UP", - Interval: 60, LastCheck: time.Now(), + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60}, + SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()}, } injectSite(e, site) @@ -585,8 +613,8 @@ func TestCheckPush_PendingStaysPending(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ - ID: 1, Name: "push", Type: "push", Status: "PENDING", - Interval: 60, + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60}, + SiteState: models.SiteState{Status: "PENDING"}, } injectSite(e, site) @@ -603,9 +631,18 @@ func TestCheckPush_PendingStaysPending(t *testing.T) { func TestCheckGroup_AllChildrenUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group", Status: "PENDING"} - child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} - child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "UP"} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + SiteState: models.SiteState{Status: "PENDING"}, + } + child1 := models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } + child2 := models.Site{ + SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, group) injectSite(e, child1) injectSite(e, child2) @@ -621,9 +658,18 @@ func TestCheckGroup_AllChildrenUp(t *testing.T) { func TestCheckGroup_OneChildDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"} - child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} - child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + SiteState: models.SiteState{Status: "UP"}, + } + child1 := models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } + child2 := models.Site{ + SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "DOWN"}, + } injectSite(e, group) injectSite(e, child1) injectSite(e, child2) @@ -639,9 +685,17 @@ func TestCheckGroup_OneChildDown(t *testing.T) { func TestCheckGroup_PausedChildIgnored(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group"} - child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} - child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN", Paused: true} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + } + child1 := models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } + child2 := models.Site{ + SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true}, + SiteState: models.SiteState{Status: "DOWN"}, + } injectSite(e, group) injectSite(e, child1) injectSite(e, child2) @@ -658,9 +712,17 @@ func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) { ms := newMockStore() ms.maintenance[3] = true e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group"} - child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} - child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + } + child1 := models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "UP"}, + } + child2 := models.Site{ + SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1}, + SiteState: models.SiteState{Status: "DOWN"}, + } injectSite(e, group) injectSite(e, child1) injectSite(e, child2) @@ -677,7 +739,10 @@ func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) { func TestCheckGroup_NoChildren(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, group) e.checkGroup(context.Background(), group) @@ -772,10 +837,13 @@ func TestInitHistory_LoadsFromDB(t *testing.T) { func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", URL: "http://old.com", Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com"}, + SiteState: models.SiteState{Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond}, + } injectSite(e, site) - updated := models.Site{ID: 1, Name: "test", URL: "http://new.com", Interval: 60} + updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60} e.UpdateSiteConfig(updated) s, _ := getSite(e, 1) @@ -796,7 +864,10 @@ func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) { func TestRemoveSite_CleansUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Type: "push", Token: "tok1", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.recordCheck(1, 5*time.Millisecond, true) @@ -816,7 +887,10 @@ func TestRemoveSite_CleansUp(t *testing.T) { func TestToggleSitePause(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) paused := e.ToggleSitePause(1) @@ -845,8 +919,14 @@ func TestToggleSitePause_NonexistentSite(t *testing.T) { func TestGetAllSites_ReturnsCopy(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"}) - injectSite(e, models.Site{ID: 2, Name: "s2", Status: "DOWN"}) + injectSite(e, models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "s1"}, + SiteState: models.SiteState{Status: "UP"}, + }) + injectSite(e, models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "s2"}, + SiteState: models.SiteState{Status: "DOWN"}, + }) sites := e.GetAllSites() if len(sites) != 2 { @@ -865,10 +945,13 @@ func TestGetAllSites_ReturnsCopy(t *testing.T) { func TestGetLiveState_ReturnsCopy(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"}) + injectSite(e, models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "s1"}, + SiteState: models.SiteState{Status: "UP"}, + }) state := e.GetLiveState() - state[1] = models.Site{Name: "mutated"} + state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}} fresh := e.GetLiveState() if fresh[1].Name == "mutated" { @@ -984,7 +1067,8 @@ func TestConcurrent_RecordHeartbeat(t *testing.T) { e := newTestEngine(ms) for i := 0; i < 10; i++ { injectSite(e, models.Site{ - ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1), Status: "UP", + SiteConfig: models.SiteConfig{ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1)}, + SiteState: models.SiteState{Status: "UP"}, }) } @@ -1002,7 +1086,10 @@ func TestConcurrent_RecordHeartbeat(t *testing.T) { func TestConcurrent_HandleStatusChangeAndGetState(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 100} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 100}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) var wg sync.WaitGroup @@ -1055,7 +1142,10 @@ func TestConcurrent_RecordCheckAndGetHistory(t *testing.T) { func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) // `site` is the stale snapshot the check ran against (Paused=false). @@ -1079,11 +1169,14 @@ func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) { func TestHandleStatusChange_ConfigEditDuringCheckSurvives(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", URL: "http://old.com", Type: "http", Status: "UP", MaxRetries: 0, Interval: 30} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com", Type: "http", MaxRetries: 0, Interval: 30}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) // Config changes mid-check. - e.UpdateSiteConfig(models.Site{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60}) + e.UpdateSiteConfig(models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60}) // Stale check (ran against http://old.com) folds its result in. e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "") @@ -1105,7 +1198,10 @@ func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) { e := newTestEngine(ms) // Snapshot the engine would have taken before evaluating staleness: // LastCheck is old, so checkPush decided "DOWN". - snap := models.Site{ID: 1, Name: "push", Type: "push", Token: "tok", Status: "UP", Interval: 10, LastCheck: time.Now().Add(-120 * time.Second)} + snap := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Token: "tok", Interval: 10}, + SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)}, + } injectSite(e, snap) // A heartbeat lands first, advancing LastCheck and confirming UP. @@ -1126,7 +1222,10 @@ func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) { func TestHandleStatusChange_RemovedSiteDropped(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.RemoveSite(1) @@ -1189,9 +1288,18 @@ func TestEngineStop_Idempotent(t *testing.T) { func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"} - child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP", Paused: true} - child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "UP", Paused: true} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, + SiteState: models.SiteState{Status: "UP"}, + } + child1 := models.Site{ + SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1, Paused: true}, + SiteState: models.SiteState{Status: "UP"}, + } + child2 := models.Site{ + SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, group) injectSite(e, child1) injectSite(e, child2) @@ -1208,7 +1316,10 @@ func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) { func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "new-monitor", Status: "PENDING", MaxRetries: 2} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "new-monitor", MaxRetries: 2}, + SiteState: models.SiteState{Status: "PENDING"}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0, "timeout") @@ -1237,7 +1348,10 @@ func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) { func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "push-mon", Status: "LATE", MaxRetries: 1} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "push-mon", MaxRetries: 1}, + SiteState: models.SiteState{Status: "LATE"}, + } injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0, "missed heartbeat") @@ -1257,7 +1371,10 @@ func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) { func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Type: "http", Status: "UP", Interval: 30} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", Interval: 30}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.probeResultsMu.Lock() @@ -1289,7 +1406,10 @@ func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) { func TestRemoveSite_CleansProbeResults(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Type: "http", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.probeResultsMu.Lock() @@ -1312,8 +1432,14 @@ func TestIsInMaintenance_UsesCache(t *testing.T) { ms := newMockStore() ms.maintenance[10] = true // direct maintenance on group e := newTestEngine(ms) - group := models.Site{ID: 10, Name: "group", Type: "group", Status: "UP"} - child := models.Site{ID: 20, Name: "child", Type: "http", ParentID: 10, Status: "UP"} + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}, + SiteState: models.SiteState{Status: "UP"}, + } + child := models.Site{ + SiteConfig: models.SiteConfig{ID: 20, Name: "child", Type: "http", ParentID: 10}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, group) injectSite(e, child) e.refreshMaintenanceCache(context.Background()) @@ -1334,7 +1460,10 @@ func TestIsInMaintenance_GlobalMaintenance(t *testing.T) { ms := newMockStore() ms.maintenance[0] = true e := newTestEngine(ms) - site := models.Site{ID: 1, Name: "test", Type: "http", Status: "UP"} + site := models.Site{ + SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"}, + SiteState: models.SiteState{Status: "UP"}, + } injectSite(e, site) e.refreshMaintenanceCache(context.Background()) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 77eb81e..db4a3d9 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -21,7 +21,7 @@ import ( type mockStore struct { storetest.BaseMock mu sync.Mutex - sites []models.Site + sites []models.SiteConfig alerts []models.AlertConfig nodes map[string]models.ProbeNode importedData *models.Backup @@ -35,7 +35,7 @@ func newMockStore() *mockStore { } } -func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil } +func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { return m.alerts, nil } @@ -252,7 +252,7 @@ func TestExport_Unauthorized_WrongKey(t *testing.T) { func TestExport_Success(t *testing.T) { ts := newTestServer(t, "secret", false) - ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}} + ts.store.sites = []models.SiteConfig{{ID: 1, Name: "example", URL: "http://example.com"}} resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil) if err != nil { @@ -299,7 +299,7 @@ func TestImport_Unauthorized(t *testing.T) { func TestImport_Success(t *testing.T) { ts := newTestServer(t, "secret", false) backup := models.Backup{ - Sites: []models.Site{{Name: "imported", URL: "http://example.com"}}, + Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}}, } body, _ := json.Marshal(backup) resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body) @@ -437,9 +437,9 @@ func TestStatusJSON_PublicDTOOnly(t *testing.T) { // take. The old version of this test injected via UpdateSiteConfig, which // no-ops for unknown IDs, so it asserted over zero sites and passed // against a server that leaked tokens. - ts.store.sites = []models.Site{{ + ts.store.sites = []models.SiteConfig{{ ID: 1, Name: "test", Type: "push", Token: "secret-token", - Hostname: "internal-host", LastError: "internal failure detail", AlertID: 3, + Hostname: "internal-host", AlertID: 3, }} ctx, cancel := context.WithCancel(context.Background()) ts.engine.Start(ctx) diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 5821225..285bec8 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -112,7 +112,7 @@ func (s *SQLStore) Init(ctx context.Context) error { return nil } -func (s *SQLStore) GetSites(ctx context.Context) ([]models.Site, error) { +func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) { bf := s.dialect.BoolFalse() query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites", @@ -123,9 +123,9 @@ func (s *SQLStore) GetSites(ctx context.Context) ([]models.Site, error) { return nil, err } defer rows.Close() - var sites []models.Site + var sites []models.SiteConfig for rows.Next() { - var st models.Site + var st models.SiteConfig if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, @@ -137,7 +137,7 @@ func (s *SQLStore) GetSites(ctx context.Context) ([]models.Site, error) { return sites, rows.Err() } -func (s *SQLStore) AddSite(ctx context.Context, site models.Site) error { +func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error { token := "" if site.Type == "push" { var err error @@ -152,7 +152,7 @@ func (s *SQLStore) AddSite(ctx context.Context, site models.Site) error { return err } -func (s *SQLStore) UpdateSite(ctx context.Context, site models.Site) error { +func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error { var existingToken string _ = s.db.QueryRowContext(ctx, s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck if site.Type == "push" && existingToken == "" { @@ -198,13 +198,13 @@ func (s *SQLStore) DeleteSite(ctx context.Context, id int) error { return nil } -func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.Site, error) { +func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) { bf := s.dialect.BoolFalse() query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s", bf, bf, s.q("?"), ) - var st models.Site + var st models.SiteConfig err := s.db.QueryRowContext(ctx, query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, @@ -246,7 +246,7 @@ func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.Aler return a, nil } -func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.Site) (int, error) { +func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) { token := "" if site.Type == "push" { var err error diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index 39fc08c..87b5355 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -33,7 +33,7 @@ func TestSiteCRUD(t *testing.T) { t.Fatalf("expected 0 sites, got %d", len(sites)) } - if err := s.AddSite(context.Background(), models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { + if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { t.Fatalf("AddSite: %v", err) } @@ -174,7 +174,7 @@ func TestUserCRUD(t *testing.T) { func TestPushTokenGeneration(t *testing.T) { s := newTestStore(t) - if err := s.AddSite(context.Background(), models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil { + if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil { t.Fatalf("AddSite: %v", err) } @@ -199,7 +199,7 @@ func TestImportExport(t *testing.T) { if err := s.AddAlert(context.Background(), "Test Alert", "webhook", map[string]string{"url": "https://example.com"}); err != nil { t.Fatalf("AddAlert: %v", err) } - if err := s.AddSite(context.Background(), models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { + if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { t.Fatalf("AddSite: %v", err) } if err := s.AddUser(context.Background(), "user1", "ssh-ed25519 KEY", "user"); err != nil { @@ -239,7 +239,7 @@ func TestImportExport(t *testing.T) { func TestImportData_WipesHistory(t *testing.T) { s := newTestStore(t) - if err := s.AddSite(context.Background(), models.Site{Name: "OldSite", URL: "https://old.com", Type: "http", Interval: 30}); err != nil { + if err := s.AddSite(context.Background(), models.SiteConfig{Name: "OldSite", URL: "https://old.com", Type: "http", Interval: 30}); err != nil { t.Fatalf("AddSite: %v", err) } if err := s.SaveCheck(context.Background(), 1, 5000, true); err != nil { @@ -253,7 +253,7 @@ func TestImportData_WipesHistory(t *testing.T) { } backup := models.Backup{ - Sites: []models.Site{{ID: 1, Name: "NewSite", URL: "https://new.com", Type: "http", Interval: 60}}, + Sites: []models.SiteConfig{{ID: 1, Name: "NewSite", URL: "https://new.com", Type: "http", Interval: 60}}, } if err := s.ImportData(context.Background(), backup); err != nil { t.Fatalf("ImportData: %v", err) @@ -314,7 +314,7 @@ func TestCheckHistory(t *testing.T) { func TestDeleteSiteCascade(t *testing.T) { s := newTestStore(t) - site := models.Site{Name: "Cascade Test", URL: "https://example.com", Interval: 30} + site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30} if err := s.AddSite(context.Background(), site); err != nil { t.Fatalf("AddSite: %v", err) } diff --git a/internal/store/store.go b/internal/store/store.go index ee70a6b..53033bb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -11,9 +11,9 @@ type Store interface { Init(ctx context.Context) error // Sites - GetSites(ctx context.Context) ([]models.Site, error) - AddSite(ctx context.Context, site models.Site) error - UpdateSite(ctx context.Context, site models.Site) error + GetSites(ctx context.Context) ([]models.SiteConfig, error) + AddSite(ctx context.Context, site models.SiteConfig) error + UpdateSite(ctx context.Context, site models.SiteConfig) error UpdateSitePaused(ctx context.Context, id int, paused bool) error DeleteSite(ctx context.Context, id int) error @@ -25,9 +25,9 @@ type Store interface { DeleteAlert(ctx context.Context, id int) error // Declarative config support - GetSiteByName(ctx context.Context, name string) (models.Site, error) + GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) - AddSiteReturningID(ctx context.Context, site models.Site) (int, error) + AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) // Users diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index d8686f9..04b04bc 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -11,9 +11,9 @@ import ( // 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 + GetSitesFunc func(ctx context.Context) ([]models.SiteConfig, error) + AddSiteFunc func(ctx context.Context, site models.SiteConfig) error + UpdateSiteFunc func(ctx context.Context, site models.SiteConfig) 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) @@ -41,21 +41,21 @@ type BaseMock struct { 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) { +func (m *BaseMock) GetSites(ctx context.Context) ([]models.SiteConfig, error) { if m.GetSitesFunc != nil { return m.GetSitesFunc(ctx) } return nil, nil } -func (m *BaseMock) AddSite(ctx context.Context, site models.Site) error { +func (m *BaseMock) AddSite(ctx context.Context, site models.SiteConfig) error { if m.AddSiteFunc != nil { return m.AddSiteFunc(ctx, site) } return nil } -func (m *BaseMock) UpdateSite(ctx context.Context, site models.Site) error { +func (m *BaseMock) UpdateSite(ctx context.Context, site models.SiteConfig) error { if m.UpdateSiteFunc != nil { return m.UpdateSiteFunc(ctx, site) } @@ -90,15 +90,17 @@ func (m *BaseMock) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ m 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) GetSiteByName(_ context.Context, _ string) (models.SiteConfig, error) { + return models.SiteConfig{}, 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) AddSiteReturningID(_ context.Context, _ models.SiteConfig) (int, error) { + return 0, nil +} func (m *BaseMock) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) { return 0, nil diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index 9f38f58..d677298 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -8,9 +8,9 @@ import ( func TestSortSitesForDisplay_GroupsFirst(t *testing.T) { sites := []models.Site{ - {ID: 3, Name: "ungrouped", Type: "http", Status: "UP"}, - {ID: 1, Name: "group-a", Type: "group", Status: "UP"}, - {ID: 2, Name: "child", Type: "http", Status: "UP", ParentID: 1}, + {SiteConfig: models.SiteConfig{ID: 3, Name: "ungrouped", Type: "http"}, SiteState: models.SiteState{Status: "UP"}}, + {SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}}, + {SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, } result := sortSitesForDisplay(sites, nil) if len(result) != 3 { @@ -29,9 +29,9 @@ func TestSortSitesForDisplay_GroupsFirst(t *testing.T) { func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) { sites := []models.Site{ - {ID: 1, Name: "group-a", Type: "group", Status: "UP"}, - {ID: 2, Name: "child-1", Type: "http", Status: "UP", ParentID: 1}, - {ID: 3, Name: "child-2", Type: "http", Status: "UP", ParentID: 1}, + {SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}}, + {SiteConfig: models.SiteConfig{ID: 2, Name: "child-1", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, + {SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, } collapsed := map[int]bool{1: true} result := sortSitesForDisplay(sites, collapsed) @@ -45,9 +45,9 @@ func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) { func TestSortSitesForDisplay_StatusOrdering(t *testing.T) { sites := []models.Site{ - {ID: 1, Name: "up-site", Type: "http", Status: "UP"}, - {ID: 2, Name: "down-site", Type: "http", Status: "DOWN"}, - {ID: 3, Name: "late-site", Type: "http", Status: "LATE"}, + {SiteConfig: models.SiteConfig{ID: 1, Name: "up-site", Type: "http"}, SiteState: models.SiteState{Status: "UP"}}, + {SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}}, + {SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}}, } result := sortSitesForDisplay(sites, nil) if result[0].Status != "DOWN" { @@ -63,9 +63,9 @@ func TestSortSitesForDisplay_StatusOrdering(t *testing.T) { func TestFilterSites(t *testing.T) { sites := []models.Site{ - {Name: "Production API"}, - {Name: "Staging API"}, - {Name: "Database"}, + {SiteConfig: models.SiteConfig{Name: "Production API"}}, + {SiteConfig: models.SiteConfig{Name: "Staging API"}}, + {SiteConfig: models.SiteConfig{Name: "Database"}}, } tests := []struct { @@ -87,7 +87,7 @@ func TestFilterSites(t *testing.T) { } func TestFilterSites_EmptyNeedle(t *testing.T) { - sites := []models.Site{{Name: "a"}, {Name: "b"}} + sites := []models.Site{{SiteConfig: models.SiteConfig{Name: "a"}}, {SiteConfig: models.SiteConfig{Name: "b"}}} got := filterSites(sites, "") if len(got) != 2 { t.Errorf("empty needle should return all, got %d", len(got)) diff --git a/internal/tui/format_test.go b/internal/tui/format_test.go index 7c02778..95b4712 100644 --- a/internal/tui/format_test.go +++ b/internal/tui/format_test.go @@ -38,13 +38,13 @@ func TestSiteOrder(t *testing.T) { site models.Site want int }{ - {"down", models.Site{Status: "DOWN"}, 0}, - {"ssl exp", models.Site{Status: "SSL EXP"}, 0}, - {"late", models.Site{Status: "LATE"}, 1}, - {"up", models.Site{Status: "UP"}, 2}, - {"pending", models.Site{Status: "PENDING"}, 3}, - {"paused up", models.Site{Status: "UP", Paused: true}, 3}, - {"paused down", models.Site{Status: "DOWN", Paused: true}, 3}, + {"down", models.Site{SiteState: models.SiteState{Status: "DOWN"}}, 0}, + {"ssl exp", models.Site{SiteState: models.SiteState{Status: "SSL EXP"}}, 0}, + {"late", models.Site{SiteState: models.SiteState{Status: "LATE"}}, 1}, + {"up", models.Site{SiteState: models.SiteState{Status: "UP"}}, 2}, + {"pending", models.Site{SiteState: models.SiteState{Status: "PENDING"}}, 3}, + {"paused up", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "UP"}}, 3}, + {"paused down", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "DOWN"}}, 3}, } for _, tt := range tests { got := siteOrder(tt.site) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index c84a270..5b1728a 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -535,7 +535,7 @@ func (m *Model) submitSiteForm() tea.Cmd { threshold = 7 } - site := models.Site{ + cfg := models.SiteConfig{ ID: m.editID, Name: d.Name, URL: d.URL, @@ -559,11 +559,8 @@ func (m *Model) submitSiteForm() tea.Cmd { st := m.store m.state = stateDashboard if m.editID > 0 { - // The engine's in-memory config updates immediately; the DB write - // follows in the Cmd. New sites enter the engine via its poll loop - // once the insert lands. - m.engine.UpdateSiteConfig(site) - return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), site) }) + m.engine.UpdateSiteConfig(cfg) + return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) }) } - return writeCmd("Add site", func() error { return st.AddSite(context.Background(), site) }) + return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) }) } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index f08338d..1a44013 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -116,7 +116,7 @@ func (*stubErr) Error() string { return "boom" } func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) { ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} m := newTestModel(ms) - m.sites = []models.Site{{ID: 1, Name: "site", Status: "DOWN"}} + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}} m.cursor = 0 m.state = stateDetail m.termWidth = 120 @@ -201,7 +201,7 @@ func TestHandleTabData_DropsStaleSeq(t *testing.T) { func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) { ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} m := newTestModel(ms) - m.sites = []models.Site{{ID: 7, Name: "site"}} + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}} m.state = stateDetail m.termWidth, m.termHeight = 120, 40 @@ -240,7 +240,7 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) { func TestSLAData_DropsStaleReply(t *testing.T) { m := newTestModel(&tuiMockStore{}) m.termWidth, m.termHeight = 120, 40 - m.sites = []models.Site{{ID: 3, Status: "UP"}} + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}} if cmd := (&m).openSLAView(m.sites[0]); cmd == nil { t.Fatal("openSLAView should return a load Cmd") @@ -264,7 +264,7 @@ func TestSLAData_DropsStaleReply(t *testing.T) { func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) { ms := &tuiMockStore{} m := newTestModel(ms) - m.sites = []models.Site{{ID: 4, Name: "s"}} + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}} m.state = stateConfirmDelete m.deleteTab = 0 m.deleteID = 4 @@ -312,7 +312,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) { func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) { ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} m := newTestModel(ms) - m.sites = []models.Site{{ID: 5, Name: "site"}} + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}} m.state = stateDashboard if (&m).detailRefreshCmd() != nil {