chore: add linter config and CI pipeline #20

Merged
lerko merged 5 commits from chore/linter-ci-pipeline into main 2026-05-24 17:56:05 +00:00
18 changed files with 246 additions and 137 deletions
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Install build tools
run: apk add --no-cache gcc musl-dev
- name: Vet
run: go vet ./...
- name: Test
run: CGO_ENABLED=1 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@v7
with:
version: v2.11.2
+29
View File
@@ -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
+34 -17
View File
@@ -76,7 +76,7 @@ func runApply(args []string) {
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") 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 == "" { if *filePath == "" {
fmt.Fprintln(os.Stderr, "error: -f flag is required") 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)") outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") 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) 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) fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan os.Signal, 1) done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
@@ -212,7 +213,7 @@ func runServe(args []string) {
flagDSN := fs.String("dsn", dbDSN, "Database DSN") flagDSN := fs.String("dsn", dbDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data") demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") 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 s store.Store
var dbErr error var dbErr error
@@ -341,13 +342,22 @@ func seedDemoData(s store.Store) {
} }
fmt.Println("Seeding demo data...") fmt.Println("Seeding demo data...")
s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}) if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil {
s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}) log.Printf("demo seed: add alert: %v", err)
s.AddAlert("Email Oncall", "email", map[string]string{ 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", "host": "smtp.example.com", "port": "587",
"user": "oncall@example.com", "pass": "replace-me", "user": "oncall@example.com", "pass": "replace-me",
"from": "oncall@example.com", "to": "team@example.com", "from": "oncall@example.com", "to": "team@example.com",
}) }); err != nil {
log.Printf("demo seed: add alert: %v", err)
return
}
alerts, _ := s.GetAllAlerts() alerts, _ := s.GetAllAlerts()
alertID := 0 alertID := 0
@@ -355,16 +365,23 @@ func seedDemoData(s store.Store) {
alertID = alerts[0].ID 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}) demoSites := []models.Site{
s.AddSite(models.Site{Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}) {Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2},
s.AddSite(models.Site{Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}) {Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3},
s.AddSite(models.Site{Name: "JSON Placeholder", URL: "https://jsonplaceholder.typicode.com/posts/1", Type: "http", Interval: 45, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 2}) {Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1},
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}) {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: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1}) {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: "Backup Cron", Type: "push", Interval: 300, AlertID: alertID, ExpiryThreshold: 7}) {Name: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1},
s.AddSite(models.Site{Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, ExpiryThreshold: 7}) {Name: "Backup Cron", Type: "push", Interval: 300, 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}) {Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, 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}) {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 { func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
+1 -1
View File
@@ -59,7 +59,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
if err == nil && resp.StatusCode == 200 { if err == nil && resp.StatusCode == 200 {
isLeaderHealthy = true isLeaderHealthy = true
resp.Body.Close() _ = resp.Body.Close()
} }
if isLeaderHealthy { if isLeaderHealthy {
-1
View File
@@ -16,7 +16,6 @@ import (
// --- Mock Store (minimal, for monitor.NewEngine) --- // --- Mock Store (minimal, for monitor.NewEngine) ---
type mockStore struct { type mockStore struct {
mu sync.Mutex
sites []models.Site sites []models.Site
} }
+5 -4
View File
@@ -33,7 +33,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
} }
insecureClient := &http.Client{ 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 { 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 { if err != nil {
return err return err
} }
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("register returned %d", resp.StatusCode) 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) sem := make(chan struct{}, 10)
var wg sync.WaitGroup var wg sync.WaitGroup
loop:
for _, site := range sites { for _, site := range sites {
select { select {
case <-ctx.Done(): case <-ctx.Done():
break break loop
default: default:
} }
wg.Add(1) wg.Add(1)
@@ -171,7 +172,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("results returned %d", resp.StatusCode) return fmt.Errorf("results returned %d", resp.StatusCode)
} }
+1 -1
View File
@@ -142,7 +142,7 @@ func WriteFile(f *File, path string) error {
_, err = os.Stdout.Write(data) _, err = os.Stdout.Write(data)
return err 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) { func LoadFile(path string) (*File, error) {
+1 -1
View File
@@ -177,7 +177,7 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
for nidStr := range m.NotificationIDs { for nidStr := range m.NotificationIDs {
var nid int var nid int
fmt.Sscanf(nidStr, "%d", &nid) _, _ = fmt.Sscanf(nidStr, "%d", &nid) //nolint:errcheck
if upkeepID, ok := alertMap[nid]; ok { if upkeepID, ok := alertMap[nid]; ok {
site.AlertID = upkeepID site.AlertID = upkeepID
break break
+1 -1
View File
@@ -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.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
} }
} }
+1 -1
View File
@@ -131,7 +131,7 @@ func runPortCheck(site models.Site) CheckResult {
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
} }
conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
+3 -3
View File
@@ -51,7 +51,7 @@ func NewEngine(s store.Store) *Engine {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
}, },
insecureClient: &http.Client{ 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) { func (e *Engine) monitorRoutine(ctx context.Context, id int) {
// Stagger initial check to avoid thundering herd on startup // 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 { select {
case <-time.After(stagger): case <-time.After(stagger):
case <-ctx.Done(): case <-ctx.Done():
@@ -323,7 +323,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if interval < 5 { if interval < 5 {
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 { select {
case <-time.After(time.Duration(interval)*time.Second + jitter): case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done(): case <-ctx.Done():
+40 -35
View File
@@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time"
) )
func checkSecret(got, want string) bool { 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) { mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
http.Error(w, "Missing token", 400) http.Error(w, "Missing token", http.StatusBadRequest)
return return
} }
if eng.RecordHeartbeat(token) { if eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
} else { } else {
http.Error(w, "Invalid Token", 404) http.Error(w, "Invalid Token", http.StatusNotFound)
} }
}) })
// 2. Health Check (For Cluster Follower) // 2. Health Check (For Cluster Follower)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
}) })
// 3. Config Export // 3. Config Export
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { 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 return
} }
data, err := s.ExportData() data, err := s.ExportData()
if err != nil { if err != nil {
log.Printf("Export failed: %v", err) log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", 500) http.Error(w, "Export failed", http.StatusInternalServerError)
return return
} }
json.NewEncoder(w).Encode(data) _ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}) })
// 4. Config Import // 4. Config Import
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var data models.Backup var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil { if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if err := s.ImportData(data); err != nil { if err := s.ImportData(data); err != nil {
log.Printf("Import failed: %v", err) log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", http.StatusInternalServerError)
return return
} }
w.Write([]byte("Import Successful")) _, _ = w.Write([]byte("Import Successful"))
}) })
// 5. Kuma Import // 5. Kuma Import
mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var kb importer.KumaBackup var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err) log.Printf("Invalid Kuma JSON: %v", err)
http.Error(w, "Invalid Kuma JSON", 400) http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return return
} }
backup := importer.ConvertKuma(&kb) backup := importer.ConvertKuma(&kb)
if err := s.ImportData(backup); err != nil { if err := s.ImportData(backup); err != nil {
log.Printf("Kuma import failed: %v", err) log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", http.StatusInternalServerError)
return 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 // 6. Probe Registration
mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) 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"` Version string `json:"version"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if req.ID == "" { if req.ID == "" {
http.Error(w, "id is required", 400) http.Error(w, "id is required", http.StatusBadRequest)
return return
} }
if err := s.RegisterNode(models.ProbeNode{ if err := s.RegisterNode(models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version, ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil { }); err != nil {
log.Printf("Probe register failed: %v", err) log.Printf("Probe register failed: %v", err)
http.Error(w, "Registration failed", 500) http.Error(w, "Registration failed", http.StatusInternalServerError)
return 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 // 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
nodeID := r.URL.Query().Get("node_id") 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) assigned = append(assigned, site)
} }
w.Header().Set("Content-Type", "application/json") 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 // 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) 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"` } `json:"results"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if req.NodeID == "" { if req.NodeID == "" {
http.Error(w, "node_id is required", 400) http.Error(w, "node_id is required", http.StatusBadRequest)
return return
} }
for _, result := range req.Results { 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) eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp)
} }
s.UpdateNodeLastSeen(req.NodeID) if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
json.NewEncoder(w).Encode(map[string]bool{"ok": true}) log.Printf("Failed to update node last seen: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}) })
// 9. Prometheus Metrics // 9. Prometheus Metrics
@@ -392,12 +395,12 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
state[id] = site state[id] = site
} }
w.Header().Set("Content-Type", "application/json") 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) 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() { go func() {
fmt.Printf("HTTP Server listening on %s\n", addr) fmt.Printf("HTTP Server listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 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 Title string
Sites []models.Site Sites []models.Site
}{Title: title, Sites: sites} }{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)
}
} }
+25 -8
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log"
_ "github.com/lib/pq" _ "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) ResetSequenceOnEmpty(db *sql.DB, table string) {}
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil {
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") log.Printf("import wipe error: %v", err)
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") }
tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE") 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) { func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil {
tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))") log.Printf("sequence reset error: %v", err)
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('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)
}
} }
+29 -10
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -98,21 +99,39 @@ func (d *SQLiteDialect) UpsertNodeSQL() string {
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
var count int var count int
db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
if count == 0 { 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) { func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("DELETE FROM sites") if _, err := tx.Exec("DELETE FROM sites"); err != nil {
tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") log.Printf("import wipe error: %v", err)
tx.Exec("DELETE FROM alerts") }
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil {
tx.Exec("DELETE FROM users") log.Printf("import wipe error: %v", err)
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") }
tx.Exec("DELETE FROM maintenance_windows") if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'") 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) {} func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
+8 -5
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"log"
"time" "time"
) )
@@ -48,14 +49,16 @@ func (s *SQLStore) Init() error {
} }
} }
for _, m := range s.dialect.MigrationsSQL() { 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 return nil
} }
func (s *SQLStore) GetSites() ([]models.Site, error) { func (s *SQLStore) GetSites() ([]models.Site, error) {
bf := s.dialect.BoolFalse() 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", "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, bf, bf,
) )
@@ -95,7 +98,7 @@ func (s *SQLStore) AddSite(site models.Site) error {
func (s *SQLStore) UpdateSite(site models.Site) error { func (s *SQLStore) UpdateSite(site models.Site) error {
var existingToken string 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 == "" { if site.Type == "push" && existingToken == "" {
var err error var err error
existingToken, err = generateToken() existingToken, err = generateToken()
@@ -125,7 +128,7 @@ func (s *SQLStore) DeleteSite(id int) error {
func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse() 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", "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("?"), bf, bf, s.q("?"),
) )
@@ -502,7 +505,7 @@ func (s *SQLStore) ImportData(data models.Backup) error {
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback() //nolint:errcheck
s.dialect.ImportWipe(tx) s.dialect.ImportWipe(tx)
-25
View File
@@ -2,8 +2,6 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models"
"strings"
"time" "time"
) )
@@ -71,26 +69,3 @@ func fmtNodeLastSeen(t time.Time) string {
} }
return fmt.Sprintf("%dh ago", int(ago.Hours())) 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
}
+9 -9
View File
@@ -301,10 +301,10 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
if inMaint { if inMaint {
return maintStyle.Render("MAINT") return maintStyle.Render("MAINT")
} }
switch { switch status {
case status == "DOWN" || status == "SSL EXP": case "DOWN", "SSL EXP":
return dangerStyle.Render(status) return dangerStyle.Render(status)
case status == "PENDING": case "PENDING":
return subtleStyle.Render(status) return subtleStyle.Render(status)
default: default:
return specialStyle.Render(status) return specialStyle.Render(status)
@@ -721,7 +721,7 @@ func (m Model) viewDetailPanel() string {
b.WriteString(breadcrumb + "\n\n") b.WriteString(breadcrumb + "\n\n")
row := func(label, value string) { 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))) 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() latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second) 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++ up++
} }
} }
b.WriteString(fmt.Sprintf("\n %s %d/%d checks up", fmt.Fprintf(&b, "\n %s %d/%d checks up",
subtleStyle.Render("Heartbeats"), subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses))) up, len(hist.Statuses))
} }
} else { } else {
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
@@ -814,10 +814,10 @@ func (m Model) viewDetailPanel() string {
} }
} }
avg := total / time.Duration(len(hist.Latencies)) 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("Min"), minL.Milliseconds(),
subtleStyle.Render("Avg"), avg.Milliseconds(), subtleStyle.Render("Avg"), avg.Milliseconds(),
subtleStyle.Render("Max"), maxL.Milliseconds())) subtleStyle.Render("Max"), maxL.Milliseconds())
} }
} }
+18 -15
View File
@@ -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 msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
if m.state == stateLogs { if m.state == stateLogs {
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
m.logViewport.LineUp(3) m.logViewport.ScrollUp(3)
} else { } else {
m.logViewport.LineDown(3) m.logViewport.ScrollDown(3)
} }
return m, nil return m, nil
} }
listLen := len(m.sites) listLen := len(m.sites)
if m.currentTab == 1 { switch m.currentTab {
case 1:
listLen = len(m.alerts) listLen = len(m.alerts)
} else if m.currentTab == 3 { case 3:
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { case 4:
listLen = len(m.maintenanceWindows) listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 { case 5:
listLen = len(m.users) listLen = len(m.users)
} }
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
@@ -364,7 +365,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "up", "k": case "up", "k":
if m.state == stateLogs { if m.state == stateLogs {
m.logViewport.LineUp(1) m.logViewport.ScrollUp(1)
} else if m.cursor > 0 { } else if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
@@ -373,7 +374,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "down", "j": case "down", "j":
if m.state == stateLogs { if m.state == stateLogs {
m.logViewport.LineDown(1) m.logViewport.ScrollDown(1)
} else { } else {
max := len(m.sites) - 1 max := len(m.sites) - 1
if m.currentTab == 1 { if m.currentTab == 1 {
@@ -645,13 +646,14 @@ func (m *Model) refreshData() {
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
listLen := len(m.sites) listLen := len(m.sites)
if m.currentTab == 1 { switch m.currentTab {
case 1:
listLen = len(m.alerts) listLen = len(m.alerts)
} else if m.currentTab == 3 { case 3:
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { case 4:
listLen = len(m.maintenanceWindows) listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 { case 5:
listLen = len(m.users) listLen = len(m.users)
} }
if listLen > 0 && m.cursor >= listLen { if listLen > 0 && m.cursor >= listLen {
@@ -709,11 +711,12 @@ func (m Model) View() string {
switch m.state { switch m.state {
case stateConfirmDelete: case stateConfirmDelete:
kind := "monitor" kind := "monitor"
if m.deleteTab == 1 { switch m.deleteTab {
case 1:
kind = "alert" kind = "alert"
} else if m.deleteTab == 4 { case 4:
kind = "maintenance window" kind = "maintenance window"
} else if m.deleteTab == 5 { case 5:
kind = "user" kind = "user"
} }
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))