From 359cff729273c38cf13c6a8d18514554ba5d1916 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 23 May 2026 22:02:06 -0400 Subject: [PATCH 1/5] chore: add golangci-lint config and fix all lint issues Add .golangci.yml enabling errcheck, staticcheck, govet, gosec, ineffassign, and unused linters. Fix 66 issues across 16 files: - Check all unchecked errors (errcheck) - Use HTTP status constants instead of numeric literals (staticcheck) - Replace deprecated LineUp/LineDown with ScrollUp/ScrollDown (staticcheck) - Convert sprintf+write patterns to fmt.Fprintf (staticcheck) - Add ReadHeaderTimeout to http.Server (gosec) - Remove unused types and functions (unused) - Add nolint comments for intentional patterns (InsecureSkipVerify, math/rand for jitter, dialect-only SQL formatting) --- .golangci.yml | 29 ++++++++++++ cmd/goupkeep/main.go | 51 ++++++++++++++-------- internal/cluster/cluster.go | 2 +- internal/cluster/cluster_test.go | 1 - internal/cluster/probe.go | 9 ++-- internal/config/export.go | 2 +- internal/importer/kuma.go | 2 +- internal/metrics/prometheus.go | 2 +- internal/monitor/checker.go | 2 +- internal/monitor/monitor.go | 6 +-- internal/server/server.go | 75 +++++++++++++++++--------------- internal/store/postgres.go | 33 ++++++++++---- internal/store/sqlite.go | 39 ++++++++++++----- internal/store/sqlstore.go | 13 +++--- internal/tui/tab_nodes.go | 25 ----------- internal/tui/tab_sites.go | 18 ++++---- internal/tui/tui.go | 33 +++++++------- 17 files changed, 205 insertions(+), 137 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5af2a64 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,29 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - staticcheck + - govet + - gosec + - ineffassign + - unused + + settings: + errcheck: + check-type-assertions: false + check-blank: false + + exclusions: + presets: + - std-error-handling + - common-false-positives + rules: + - path: _test\.go + linters: + - errcheck + - gosec + +run: + timeout: 5m diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index b2529ae..54abb8b 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -76,7 +76,7 @@ func runApply(args []string) { prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: parse errors exit before returning if *filePath == "" { fmt.Fprintln(os.Stderr, "error: -f flag is required") @@ -109,7 +109,7 @@ func runExport(args []string) { outPath := fs.String("o", "-", "Output file path (- for stdout)") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: parse errors exit before returning s := openStore(*dbType, *dsn) @@ -186,6 +186,7 @@ func runServe(args []string) { fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { @@ -212,7 +213,7 @@ func runServe(args []string) { flagDSN := fs.String("dsn", dbDSN, "Database DSN") demo := fs.Bool("demo", false, "Seed demo data") importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: parse errors exit before returning var s store.Store var dbErr error @@ -341,13 +342,22 @@ func seedDemoData(s store.Store) { } fmt.Println("Seeding demo data...") - s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}) - s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}) - s.AddAlert("Email Oncall", "email", map[string]string{ + if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil { + log.Printf("demo seed: add alert: %v", err) + return + } + if err := s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil { + log.Printf("demo seed: add alert: %v", err) + return + } + if err := s.AddAlert("Email Oncall", "email", map[string]string{ "host": "smtp.example.com", "port": "587", "user": "oncall@example.com", "pass": "replace-me", "from": "oncall@example.com", "to": "team@example.com", - }) + }); err != nil { + log.Printf("demo seed: add alert: %v", err) + return + } alerts, _ := s.GetAllAlerts() alertID := 0 @@ -355,16 +365,23 @@ func seedDemoData(s store.Store) { alertID = alerts[0].ID } - s.AddSite(models.Site{Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2}) - s.AddSite(models.Site{Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}) - s.AddSite(models.Site{Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}) - s.AddSite(models.Site{Name: "JSON Placeholder", URL: "https://jsonplaceholder.typicode.com/posts/1", Type: "http", Interval: 45, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 2}) - s.AddSite(models.Site{Name: "Nonexistent Site", URL: "https://this-domain-does-not-exist-12345.com", Type: "http", Interval: 30, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 3}) - s.AddSite(models.Site{Name: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1}) - s.AddSite(models.Site{Name: "Backup Cron", Type: "push", Interval: 300, AlertID: alertID, ExpiryThreshold: 7}) - s.AddSite(models.Site{Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, ExpiryThreshold: 7}) - s.AddSite(models.Site{Name: "Gateway", Type: "ping", Interval: 30, AlertID: alertID, Hostname: "10.0.0.1", Timeout: 5, ExpiryThreshold: 7}) - s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}) + demoSites := []models.Site{ + {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}, + {Name: "JSON Placeholder", URL: "https://jsonplaceholder.typicode.com/posts/1", Type: "http", Interval: 45, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 2}, + {Name: "Nonexistent Site", URL: "https://this-domain-does-not-exist-12345.com", Type: "http", Interval: 30, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 3}, + {Name: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1}, + {Name: "Backup Cron", Type: "push", Interval: 300, AlertID: alertID, ExpiryThreshold: 7}, + {Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, ExpiryThreshold: 7}, + {Name: "Gateway", Type: "ping", Interval: 30, AlertID: alertID, Hostname: "10.0.0.1", Timeout: 5, ExpiryThreshold: 7}, + {Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}, + } + for _, site := range demoSites { + if err := s.AddSite(site); err != nil { + log.Printf("demo seed: add site %q: %v", site.Name, err) + } + } } func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 03ec751..53cf847 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -59,7 +59,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) { if err == nil && resp.StatusCode == 200 { isLeaderHealthy = true - resp.Body.Close() + _ = resp.Body.Close() } if isLeaderHealthy { diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index 5f062f9..63eec88 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -16,7 +16,6 @@ import ( // --- Mock Store (minimal, for monitor.NewEngine) --- type mockStore struct { - mu sync.Mutex sites []models.Site } diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go index 6df0a36..4a04687 100644 --- a/internal/cluster/probe.go +++ b/internal/cluster/probe.go @@ -33,7 +33,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error { Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, } insecureClient := &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites } if err := probeRegister(ctx, apiClient, cfg); err != nil { @@ -85,7 +85,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("register returned %d", resp.StatusCode) } @@ -127,10 +127,11 @@ func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecu sem := make(chan struct{}, 10) var wg sync.WaitGroup +loop: for _, site := range sites { select { case <-ctx.Done(): - break + break loop default: } wg.Add(1) @@ -171,7 +172,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("results returned %d", resp.StatusCode) } diff --git a/internal/config/export.go b/internal/config/export.go index a8cb981..dd9bdc6 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -142,7 +142,7 @@ func WriteFile(f *File, path string) error { _, err = os.Stdout.Write(data) return err } - return os.WriteFile(path, data, 0644) + return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable } func LoadFile(path string) (*File, error) { diff --git a/internal/importer/kuma.go b/internal/importer/kuma.go index 8226d3f..953e5d1 100644 --- a/internal/importer/kuma.go +++ b/internal/importer/kuma.go @@ -177,7 +177,7 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site { for nidStr := range m.NotificationIDs { var nid int - fmt.Sscanf(nidStr, "%d", &nid) + _, _ = fmt.Sscanf(nidStr, "%d", &nid) //nolint:errcheck if upkeepID, ok := alertMap[nid]; ok { site.AlertID = upkeepID break diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index 4477db8..6e6e39e 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -97,7 +97,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc { } w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") - w.Write([]byte(b.String())) + _, _ = w.Write([]byte(b.String())) //nolint:errcheck } } diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go index be62155..0fadd5f 100644 --- a/internal/monitor/checker.go +++ b/internal/monitor/checker.go @@ -131,7 +131,7 @@ func runPortCheck(site models.Site) CheckResult { if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} } - conn.Close() + _ = conn.Close() return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index bcd4371..c2b334e 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -51,7 +51,7 @@ func NewEngine(s store.Store) *Engine { Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, }, insecureClient: &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites }, } } @@ -279,7 +279,7 @@ func (e *Engine) ToggleSitePause(id int) bool { func (e *Engine) monitorRoutine(ctx context.Context, id int) { // Stagger initial check to avoid thundering herd on startup - stagger := time.Duration(rand.IntN(3000)) * time.Millisecond + stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter select { case <-time.After(stagger): case <-ctx.Done(): @@ -323,7 +323,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) { if interval < 5 { interval = 5 } - jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond + jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter select { case <-time.After(time.Duration(interval)*time.Second + jitter): case <-ctx.Done(): diff --git a/internal/server/server.go b/internal/server/server.go index 8b18667..2e94214 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "net/http" "sort" "strings" + "time" ) func checkSecret(got, want string) bool { @@ -168,100 +169,100 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { - http.Error(w, "Missing token", 400) + http.Error(w, "Missing token", http.StatusBadRequest) return } if eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + _, _ = w.Write([]byte("OK")) } else { - http.Error(w, "Invalid Token", 404) + http.Error(w, "Invalid Token", http.StatusNotFound) } }) // 2. Health Check (For Cluster Follower) mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + _, _ = w.Write([]byte("OK")) }) // 3. Config Export mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) + http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized) return } data, err := s.ExportData() if err != nil { log.Printf("Export failed: %v", err) - http.Error(w, "Export failed", 500) + http.Error(w, "Export failed", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(data) + _ = json.NewEncoder(w).Encode(data) //nolint:errcheck }) // 4. Config Import mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - http.Error(w, "POST required", 405) + http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var data models.Backup if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - http.Error(w, "Invalid JSON", 400) + http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if err := s.ImportData(data); err != nil { log.Printf("Import failed: %v", err) - http.Error(w, "Import failed", 500) + http.Error(w, "Import failed", http.StatusInternalServerError) return } - w.Write([]byte("Import Successful")) + _, _ = w.Write([]byte("Import Successful")) }) // 5. Kuma Import mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - http.Error(w, "POST required", 405) + http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var kb importer.KumaBackup if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { log.Printf("Invalid Kuma JSON: %v", err) - http.Error(w, "Invalid Kuma JSON", 400) + http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest) return } backup := importer.ConvertKuma(&kb) if err := s.ImportData(backup); err != nil { log.Printf("Kuma import failed: %v", err) - http.Error(w, "Import failed", 500) + http.Error(w, "Import failed", http.StatusInternalServerError) return } - w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) + fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version) }) // 6. Probe Registration mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - http.Error(w, "POST required", 405) + http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) @@ -272,27 +273,27 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { Version string `json:"version"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", 400) + http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.ID == "" { - http.Error(w, "id is required", 400) + http.Error(w, "id is required", http.StatusBadRequest) return } if err := s.RegisterNode(models.ProbeNode{ ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version, }); err != nil { log.Printf("Probe register failed: %v", err) - http.Error(w, "Registration failed", 500) + http.Error(w, "Registration failed", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck }) // 7. Probe Assignment Fetch mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } nodeID := r.URL.Query().Get("node_id") @@ -323,17 +324,17 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { assigned = append(assigned, site) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) + _ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck }) // 8. Probe Result Submission mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - http.Error(w, "POST required", 405) + http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { - http.Error(w, "Unauthorized", 401) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) @@ -346,11 +347,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { } `json:"results"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", 400) + http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.NodeID == "" { - http.Error(w, "node_id is required", 400) + http.Error(w, "node_id is required", http.StatusBadRequest) return } for _, result := range req.Results { @@ -359,8 +360,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { } eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp) } - s.UpdateNodeLastSeen(req.NodeID) - json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + if err := s.UpdateNodeLastSeen(req.NodeID); err != nil { + log.Printf("Failed to update node last seen: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck }) // 9. Prometheus Metrics @@ -392,12 +395,12 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { state[id] = site } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(state) + _ = json.NewEncoder(w).Encode(state) //nolint:errcheck }) } addr := fmt.Sprintf(":%d", cfg.Port) - srv := &http.Server{Addr: addr, Handler: mux} + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} go func() { fmt.Printf("HTTP Server listening on %s\n", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -426,5 +429,7 @@ func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) Title string Sites []models.Site }{Title: title, Sites: sites} - statusTpl.Execute(w, data) + if err := statusTpl.Execute(w, data); err != nil { + log.Printf("Failed to render status page: %v", err) + } } diff --git a/internal/store/postgres.go b/internal/store/postgres.go index f119a37..aa57316 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "log" _ "github.com/lib/pq" ) @@ -99,15 +100,31 @@ func (d *PostgresDialect) UpsertNodeSQL() string { func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {} func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { - tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") - tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") - tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") - tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE") + if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil { + log.Printf("import wipe error: %v", err) + } } func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { - tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") - tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))") - tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))") - tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))") + if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil { + log.Printf("sequence reset error: %v", err) + } + if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil { + log.Printf("sequence reset error: %v", err) + } + if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil { + log.Printf("sequence reset error: %v", err) + } + if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil { + log.Printf("sequence reset error: %v", err) + } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 5cab498..3de2c95 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "log" _ "github.com/mattn/go-sqlite3" ) @@ -98,21 +99,39 @@ func (d *SQLiteDialect) UpsertNodeSQL() string { func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { var count int - db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) + _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck if count == 0 { - db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table) + if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil { + log.Printf("sequence cleanup error: %v", err) + } } } func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) { - tx.Exec("DELETE FROM sites") - tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") - tx.Exec("DELETE FROM alerts") - tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") - tx.Exec("DELETE FROM users") - tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") - tx.Exec("DELETE FROM maintenance_windows") - tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'") + if _, err := tx.Exec("DELETE FROM sites"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM alerts"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM users"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil { + log.Printf("import wipe error: %v", err) + } + if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil { + log.Printf("import wipe error: %v", err) + } } func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {} diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 63bc096..24914bf 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "go-upkeep/internal/models" + "log" "time" ) @@ -48,14 +49,16 @@ func (s *SQLStore) Init() error { } } for _, m := range s.dialect.MigrationsSQL() { - s.db.Exec(m) + if _, err := s.db.Exec(m); err != nil { + log.Printf("migration error: %v", err) + } } return nil } func (s *SQLStore) GetSites() ([]models.Site, error) { bf := s.dialect.BoolFalse() - query := fmt.Sprintf( + 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", bf, bf, ) @@ -95,7 +98,7 @@ func (s *SQLStore) AddSite(site models.Site) error { func (s *SQLStore) UpdateSite(site models.Site) error { var existingToken string - s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) + _ = s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck if site.Type == "push" && existingToken == "" { var err error existingToken, err = generateToken() @@ -125,7 +128,7 @@ func (s *SQLStore) DeleteSite(id int) error { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { bf := s.dialect.BoolFalse() - query := fmt.Sprintf( + 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("?"), ) @@ -502,7 +505,7 @@ func (s *SQLStore) ImportData(data models.Backup) error { if err != nil { return err } - defer tx.Rollback() + defer tx.Rollback() //nolint:errcheck s.dialect.ImportWipe(tx) diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index 60f9522..a4a049c 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -2,8 +2,6 @@ package tui import ( "fmt" - "go-upkeep/internal/models" - "strings" "time" ) @@ -71,26 +69,3 @@ func fmtNodeLastSeen(t time.Time) string { } return fmt.Sprintf("%dh ago", int(ago.Hours())) } - -func fmtProbeRegions(site models.Site, probeResults map[string]probeStatus) string { - if len(probeResults) == 0 { - return subtleStyle.Render("—") - } - var parts []string - for region, status := range probeResults { - short := region - if len(short) > 6 { - short = short[:6] - } - if status.isUp { - parts = append(parts, specialStyle.Render(short+":UP")) - } else { - parts = append(parts, dangerStyle.Render(short+":DN")) - } - } - return strings.Join(parts, " ") -} - -type probeStatus struct { - isUp bool -} diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ac89ce1..eac0978 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -301,10 +301,10 @@ func fmtStatus(status string, paused bool, inMaint bool) string { if inMaint { return maintStyle.Render("MAINT") } - switch { - case status == "DOWN" || status == "SSL EXP": + switch status { + case "DOWN", "SSL EXP": return dangerStyle.Render(status) - case status == "PENDING": + case "PENDING": return subtleStyle.Render(status) default: return specialStyle.Render(status) @@ -721,7 +721,7 @@ func (m Model) viewDetailPanel() string { b.WriteString(breadcrumb + "\n\n") row := func(label, value string) { - b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) } row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) @@ -780,7 +780,7 @@ func (m Model) viewDetailPanel() string { } latency := time.Duration(result.LatencyNs).Milliseconds() ago := time.Since(result.CheckedAt).Truncate(time.Second) - b.WriteString(fmt.Sprintf(" %-14s %s %dms %s ago\n", nodeID, status, latency, ago)) + fmt.Fprintf(&b, " %-14s %s %dms %s ago\n", nodeID, status, latency, ago) } } @@ -795,9 +795,9 @@ func (m Model) viewDetailPanel() string { up++ } } - b.WriteString(fmt.Sprintf("\n %s %d/%d checks up", + fmt.Fprintf(&b, "\n %s %d/%d checks up", subtleStyle.Render("Heartbeats"), - up, len(hist.Statuses))) + up, len(hist.Statuses)) } } else { b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) @@ -814,10 +814,10 @@ func (m Model) viewDetailPanel() string { } } avg := total / time.Duration(len(hist.Latencies)) - b.WriteString(fmt.Sprintf("\n %s %dms %s %dms %s %dms", + fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms", subtleStyle.Render("Min"), minL.Milliseconds(), subtleStyle.Render("Avg"), avg.Milliseconds(), - subtleStyle.Render("Max"), maxL.Milliseconds())) + subtleStyle.Render("Max"), maxL.Milliseconds()) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a992ca6..eaef5d6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -264,20 +264,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { if m.state == stateLogs { if msg.Button == tea.MouseButtonWheelUp { - m.logViewport.LineUp(3) + m.logViewport.ScrollUp(3) } else { - m.logViewport.LineDown(3) + m.logViewport.ScrollDown(3) } return m, nil } listLen := len(m.sites) - if m.currentTab == 1 { + switch m.currentTab { + case 1: listLen = len(m.alerts) - } else if m.currentTab == 3 { + case 3: listLen = len(m.nodes) - } else if m.currentTab == 4 { + case 4: listLen = len(m.maintenanceWindows) - } else if m.currentTab == 5 { + case 5: listLen = len(m.users) } if msg.Button == tea.MouseButtonWheelUp { @@ -364,7 +365,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "up", "k": if m.state == stateLogs { - m.logViewport.LineUp(1) + m.logViewport.ScrollUp(1) } else if m.cursor > 0 { m.cursor-- if m.cursor < m.tableOffset { @@ -373,7 +374,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "down", "j": if m.state == stateLogs { - m.logViewport.LineDown(1) + m.logViewport.ScrollDown(1) } else { max := len(m.sites) - 1 if m.currentTab == 1 { @@ -645,13 +646,14 @@ func (m *Model) refreshData() { m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) listLen := len(m.sites) - if m.currentTab == 1 { + switch m.currentTab { + case 1: listLen = len(m.alerts) - } else if m.currentTab == 3 { + case 3: listLen = len(m.nodes) - } else if m.currentTab == 4 { + case 4: listLen = len(m.maintenanceWindows) - } else if m.currentTab == 5 { + case 5: listLen = len(m.users) } if listLen > 0 && m.cursor >= listLen { @@ -709,11 +711,12 @@ func (m Model) View() string { switch m.state { case stateConfirmDelete: kind := "monitor" - if m.deleteTab == 1 { + switch m.deleteTab { + case 1: kind = "alert" - } else if m.deleteTab == 4 { + case 4: kind = "maintenance window" - } else if m.deleteTab == 5 { + case 5: kind = "user" } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) -- 2.52.0 From fb3f96f608798bb75955ff673c3cbe354b2df1b1 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 23 May 2026 22:02:26 -0400 Subject: [PATCH 2/5] ci: add Gitea Actions pipeline for test and lint Runs on push to main and on pull requests. Two parallel jobs: - test: go vet + go test -race - lint: golangci-lint via official action --- .gitea/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3453231 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -timeout 120s ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - uses: golangci/golangci-lint-action@v6 + with: + version: v2.11.2 -- 2.52.0 From 6d7ecc46eba81e762dfce14aceca61569340fe8a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 24 May 2026 12:42:49 -0400 Subject: [PATCH 3/5] fix(ci): use sh instead of bash for runner compatibility --- .gitea/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3453231..0c0a130 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,6 +8,9 @@ on: jobs: test: runs-on: ubuntu-latest + defaults: + run: + shell: sh steps: - uses: actions/checkout@v4 -- 2.52.0 From 5915e0ebe33b2dd513b4a1e91aaeeed3691d8221 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 24 May 2026 12:45:28 -0400 Subject: [PATCH 4/5] fix(ci): enable CGO for race detector, use lint-action v7 --- .gitea/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0c0a130..0e061a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: run: go vet ./... - name: Test - run: go test -race -timeout 120s ./... + run: CGO_ENABLED=1 go test -race -timeout 120s ./... lint: runs-on: ubuntu-latest @@ -33,6 +33,6 @@ jobs: with: go-version: "1.24" - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v7 with: version: v2.11.2 -- 2.52.0 From 26268bb6ef324f3de92b338f3e1419485ce5ef93 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 24 May 2026 12:49:21 -0400 Subject: [PATCH 5/5] fix(ci): install gcc for race detector support --- .gitea/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0e061a3..3493f2a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: with: go-version: "1.24" + - name: Install build tools + run: apk add --no-cache gcc musl-dev + - name: Vet run: go vet ./... -- 2.52.0