From d5ab3a18a4a93d4bdf7a7da16186c2953c12a904 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 18:46:17 -0400 Subject: [PATCH 01/35] feat(tui,status): add per-site pause, fix viewport, polish status page Per-site pause: [p] key toggles pause for selected monitor in TUI. Paused monitors skip checks, persist to DB, show on status page. Status page: replace full-page reload with fetch-based DOM updates to eliminate scroll-jump on refresh. Add summary bar (UP/DOWN/PAUSED counts), stale-data indicator, and fix SSL EXP CSS class bug. TUI: constrain tables to terminal width via lipgloss .Width() to prevent row wrapping that pushed header off-screen. Add MaxHeight safety net. Bump subtle style from #383838 to #565f89 for readability on dark terminals. --- internal/models/models.go | 1 + internal/monitor/monitor.go | 26 ++++++++- internal/server/server.go | 105 +++++++++++++++++++++++++++++++----- internal/store/postgres.go | 23 ++++---- internal/store/sqlite.go | 23 ++++---- internal/store/store.go | 1 + internal/tui/tab_alerts.go | 22 ++++---- internal/tui/tab_sites.go | 29 +++++----- internal/tui/tab_users.go | 22 ++++---- internal/tui/tui.go | 24 +++++++-- 10 files changed, 199 insertions(+), 77 deletions(-) diff --git a/internal/models/models.go b/internal/models/models.go index f79c9c5..467fd33 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -24,6 +24,7 @@ type Site struct { DNSResolveType string DNSServer string IgnoreTLS bool + Paused bool FailureCount int Status string diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 791aac1..76a55bb 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -162,6 +162,7 @@ func UpdateSiteConfig(site models.Site) { s.DNSResolveType = site.DNSResolveType s.DNSServer = site.DNSServer s.IgnoreTLS = site.IgnoreTLS + s.Paused = site.Paused LiveState[site.ID] = s } } @@ -173,10 +174,26 @@ func RemoveSite(id int) { RemoveHistory(id) } +func ToggleSitePause(id int) bool { + Mutex.Lock() + defer Mutex.Unlock() + site, ok := LiveState[id] + if !ok { + return false + } + site.Paused = !site.Paused + LiveState[id] = site + if site.Paused { + AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name)) + } else { + AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name)) + } + return site.Paused +} + func monitorRoutine(id int) { checkByID(id) for { - // If paused, just sleep loop to keep goroutine alive but idle if !IsEngineActive() { time.Sleep(5 * time.Second) continue @@ -189,6 +206,11 @@ func monitorRoutine(id int) { return } + if site.Paused { + time.Sleep(5 * time.Second) + continue + } + interval := site.Interval if interval < 5 { interval = 5 @@ -206,7 +228,7 @@ func checkByID(id int) { Mutex.RLock() site, exists := LiveState[id] Mutex.RUnlock() - if !exists { + if !exists || site.Paused { return } switch site.Type { diff --git a/internal/server/server.go b/internal/server/server.go index 6e857b4..89460ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -148,7 +148,6 @@ func renderStatusPage(w http.ResponseWriter, title string) { {{.Title}} -

{{.Title}}

- {{range .Sites}} -
-
-
{{.Name}}
-
{{.Type}} | {{if eq .Type "http"}}{{.URL}}{{else}}Heartbeat Monitor{{end}}
-
Last Check: {{.LastCheck.Format "15:04:05"}}
-
-
{{.Status}}
-
- {{end}} +
+
+
Powered by Go-Upkeep
` diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 34ce96b..ea7f58b 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -47,7 +47,8 @@ func (p *PostgresStore) Init() error { accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT FALSE + ignore_tls BOOLEAN DEFAULT FALSE, + paused BOOLEAN DEFAULT FALSE );`, `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -73,6 +74,7 @@ func (p *PostgresStore) Init() error { "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", } for _, m := range migrations { p.db.Exec(m) @@ -83,7 +85,7 @@ func (p *PostgresStore) Init() error { // ... [CRUD Methods are identical to Phase 4, keeping them concise here] ... func (p *PostgresStore) GetSites() []models.Site { - rows, err := p.db.Query("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, FALSE) FROM sites") + rows, err := p.db.Query("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, FALSE), COALESCE(paused, FALSE) FROM sites") if err != nil { return []models.Site{} } @@ -92,7 +94,7 @@ func (p *PostgresStore) GetSites() []models.Site { for rows.Next() { var s models.Site rows.Scan(&s.ID, &s.Name, &s.URL, &s.Type, &s.Token, &s.Interval, &s.AlertID, &s.CheckSSL, &s.ExpiryThreshold, &s.MaxRetries, - &s.Hostname, &s.Port, &s.Timeout, &s.Method, &s.Description, &s.ParentID, &s.AcceptedCodes, &s.DNSResolveType, &s.DNSServer, &s.IgnoreTLS) + &s.Hostname, &s.Port, &s.Timeout, &s.Method, &s.Description, &s.ParentID, &s.AcceptedCodes, &s.DNSResolveType, &s.DNSServer, &s.IgnoreTLS, &s.Paused) sites = append(sites, s) } return sites @@ -102,9 +104,9 @@ func (p *PostgresStore) AddSite(site models.Site) { if site.Type == "push" { token = generateToken() } - p.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)", + p.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) } func (p *PostgresStore) UpdateSite(site models.Site) { var existingToken string @@ -112,9 +114,12 @@ func (p *PostgresStore) UpdateSite(site models.Site) { if site.Type == "push" && existingToken == "" { existingToken = generateToken() } - p.db.Exec("UPDATE sites SET name=$1, url=$2, type=$3, token=$4, interval=$5, alert_id=$6, check_ssl=$7, threshold=$8, max_retries=$9, hostname=$10, port=$11, timeout=$12, method=$13, description=$14, parent_id=$15, accepted_codes=$16, dns_resolve_type=$17, dns_server=$18, ignore_tls=$19 WHERE id=$20", + p.db.Exec("UPDATE sites SET name=$1, url=$2, type=$3, token=$4, interval=$5, alert_id=$6, check_ssl=$7, threshold=$8, max_retries=$9, hostname=$10, port=$11, timeout=$12, method=$13, description=$14, parent_id=$15, accepted_codes=$16, dns_resolve_type=$17, dns_server=$18, ignore_tls=$19, paused=$20 WHERE id=$21", site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.ID) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) +} +func (p *PostgresStore) UpdateSitePaused(id int, paused bool) { + p.db.Exec("UPDATE sites SET paused=$1 WHERE id=$2", paused, id) } func (p *PostgresStore) DeleteSite(id int) { p.db.Exec("DELETE FROM sites WHERE id=$1", id) } func (p *PostgresStore) GetAllAlerts() []models.AlertConfig { @@ -207,9 +212,9 @@ func (p *PostgresStore) ImportData(data models.Backup) error { tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES ($1, $2, $3, $4)", a.ID, a.Name, a.Type, string(jsonBytes)) } for _, st := range data.Sites { - tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", + tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", 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, st.DNSServer, st.IgnoreTLS) + st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused) } tx.Exec("SELECT setval('sites_id_seq', (SELECT MAX(id) FROM sites))") diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index d55a35e..a0331cd 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -49,7 +49,8 @@ func (s *SQLiteStore) Init() error { accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT 0 + ignore_tls BOOLEAN DEFAULT 0, + paused BOOLEAN DEFAULT 0 ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -73,6 +74,7 @@ func (s *SQLiteStore) Init() error { "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", + "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", } for _, m := range migrations { s.db.Exec(m) @@ -90,7 +92,7 @@ func generateToken() string { } func (s *SQLiteStore) GetSites() []models.Site { - rows, err := s.db.Query("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, 0) FROM sites") + rows, err := s.db.Query("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, 0), COALESCE(paused, 0) FROM sites") if err != nil { return []models.Site{} } @@ -98,7 +100,7 @@ func (s *SQLiteStore) GetSites() []models.Site { var sites []models.Site for rows.Next() { var st models.Site - 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, &st.DNSServer, &st.IgnoreTLS) + 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, &st.DNSServer, &st.IgnoreTLS, &st.Paused) sites = append(sites, st) } return sites @@ -108,9 +110,9 @@ func (s *SQLiteStore) AddSite(site models.Site) { if site.Type == "push" { token = generateToken() } - s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) } func (s *SQLiteStore) UpdateSite(site models.Site) { var existingToken string @@ -118,9 +120,12 @@ func (s *SQLiteStore) UpdateSite(site models.Site) { if site.Type == "push" && existingToken == "" { existingToken = generateToken() } - s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=? WHERE id=?", + s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?", site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.ID) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) +} +func (s *SQLiteStore) UpdateSitePaused(id int, paused bool) { + s.db.Exec("UPDATE sites SET paused=? WHERE id=?", paused, id) } func (s *SQLiteStore) DeleteSite(id int) { s.db.Exec("DELETE FROM sites WHERE id=?", id) @@ -232,9 +237,9 @@ func (s *SQLiteStore) ImportData(data models.Backup) error { tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)", a.ID, a.Name, a.Type, string(jsonBytes)) } for _, st := range data.Sites { - tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 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, st.DNSServer, st.IgnoreTLS) + st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused) } return tx.Commit() diff --git a/internal/store/store.go b/internal/store/store.go index e9c05ac..85b47f6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -11,6 +11,7 @@ type Store interface { GetSites() []models.Site AddSite(site models.Site) UpdateSite(site models.Site) + UpdateSitePaused(id int, paused bool) DeleteSite(id int) // Alerts diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index c0ee2ec..4aa9c54 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -26,8 +26,6 @@ var ( alertBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) - - alertColWidths = []int{4, 16, 10, 36} ) type alertFormData struct { @@ -120,27 +118,25 @@ func (m Model) viewAlertsTab() string { }) } + tableWidth := m.termWidth - 6 + if tableWidth < 40 { + tableWidth = 40 + } + t := table.New(). Border(lipgloss.RoundedBorder()). BorderStyle(alertBorderStyle). + Width(tableWidth). Headers("ID", "NAME", "TYPE", "CONFIG"). Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - s := alertHeaderStyle - if col < len(alertColWidths) { - s = s.Width(alertColWidths[col]) - } - return s + return alertHeaderStyle } - s := alertCellStyle if row == selectedVisual { - s = alertSelectedStyle + return alertSelectedStyle } - if col < len(alertColWidths) { - s = s.Width(alertColWidths[col]) - } - return s + return alertCellStyle }) return "\n" + t.Render() diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 2ac265b..8d7a6dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -34,8 +34,6 @@ var ( siteBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) - - siteColWidths = []int{4, 14, 6, 8, 9, 8, 20, 10, 6} ) type siteFormData struct { @@ -195,7 +193,10 @@ func fmtRetries(site models.Site) string { return s } -func fmtStatus(status string) string { +func fmtStatus(status string, paused bool) string { + if paused { + return warnStyle.Render("PAUSED") + } switch { case status == "DOWN" || status == "SSL EXP": return dangerStyle.Render(status) @@ -236,7 +237,7 @@ func (m Model) viewSitesTab() string { strconv.Itoa(site.ID), m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)), site.Type, - fmtStatus(site.Status), + fmtStatus(site.Status, site.Paused), fmtLatency(site.Latency), fmtUptime(hist.TotalChecks, hist.UpChecks), spark, @@ -245,27 +246,25 @@ func (m Model) viewSitesTab() string { }) } + tableWidth := m.termWidth - 6 + if tableWidth < 40 { + tableWidth = 40 + } + t := table.New(). Border(lipgloss.RoundedBorder()). BorderStyle(siteBorderStyle). + Width(tableWidth). Headers("ID", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - s := siteHeaderStyle - if col < len(siteColWidths) { - s = s.Width(siteColWidths[col]) - } - return s + return siteHeaderStyle } - s := siteCellStyle if row == selectedVisual { - s = siteSelectedStyle + return siteSelectedStyle } - if col < len(siteColWidths) { - s = s.Width(siteColWidths[col]) - } - return s + return siteCellStyle }) return "\n" + t.Render() diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 48fd9a3..14f9a93 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -26,8 +26,6 @@ var ( userBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) - - userColWidths = []int{4, 16, 10, 44} ) type userFormData struct { @@ -73,27 +71,25 @@ func (m Model) viewUsersTab() string { }) } + tableWidth := m.termWidth - 6 + if tableWidth < 40 { + tableWidth = 40 + } + t := table.New(). Border(lipgloss.RoundedBorder()). BorderStyle(userBorderStyle). + Width(tableWidth). Headers("ID", "USERNAME", "ROLE", "PUBLIC KEY"). Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - s := userHeaderStyle - if col < len(userColWidths) { - s = s.Width(userColWidths[col]) - } - return s + return userHeaderStyle } - s := userCellStyle if row == selectedVisual { - s = userSelectedStyle + return userSelectedStyle } - if col < len(userColWidths) { - s = s.Width(userColWidths[col]) - } - return s + return userCellStyle }) return "\n" + t.Render() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7af994b..6f9c5ed 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,7 +19,7 @@ import ( ) var ( - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"}) specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) @@ -48,6 +48,8 @@ type Model struct { cursor int tableOffset int maxTableRows int + termWidth int + termHeight int editID int editToken string @@ -126,6 +128,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height m.maxTableRows = msg.Height - 12 if m.maxTableRows < 1 { m.maxTableRows = 1 @@ -255,6 +259,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateFormUser return m, m.initUserHuhForm() } + case "p": + if m.currentTab == 0 && len(m.sites) > 0 { + site := m.sites[m.cursor] + monitor.ToggleSitePause(site.ID) + site.Paused = !site.Paused + if store.Get() != nil { + store.Get().UpdateSitePaused(site.ID, site.Paused) + } + m.refreshData() + } case "d", "backspace": if m.currentTab == 1 && len(m.alerts) > 0 { store.Get().DeleteAlert(m.alerts[m.cursor].ID) @@ -476,11 +490,15 @@ func (m Model) viewDashboard() string { } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") if m.currentTab == 3 { footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") } - return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n" + content + "\n" + footer) + s := lipgloss.NewStyle().Padding(1, 2) + if m.termHeight > 0 { + s = s.MaxHeight(m.termHeight) + } + return s.Render(header + "\n" + content + "\n" + footer) } func limitStr(text string, max int) string { From e97780ad384ce555248229f3dab93046cc4e8257 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 20:51:06 -0400 Subject: [PATCH 02/35] fix(tui,status,store): add delete confirm, input validation, XSS fix, history persistence Prevent accidental deletes with y/n confirmation dialog. Validate all numeric form inputs (interval, port, timeout, threshold, retries) with range checks instead of silently defaulting to zero. Escape user-supplied data in status page JavaScript to close XSS via monitor names. Persist check history to new check_history table so sparklines and uptime percentages survive restarts. --- cmd/goupkeep/main.go | 1 + internal/models/models.go | 8 +++- internal/monitor/history.go | 30 +++++++++++++++ internal/server/server.go | 12 ++++-- internal/store/postgres.go | 42 ++++++++++++++++++++- internal/store/sqlite.go | 43 ++++++++++++++++++++- internal/store/store.go | 6 ++- internal/tui/tab_sites.go | 60 ++++++++++++++++++++++++++--- internal/tui/tui.go | 75 +++++++++++++++++++++++++++++++------ 9 files changed, 253 insertions(+), 24 deletions(-) diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 0ae9cb0..26c01b3 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -112,6 +112,7 @@ func main() { fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) } + monitor.InitHistoryFromStore() monitor.StartEngine() server.Start(server.ServerConfig{ diff --git a/internal/models/models.go b/internal/models/models.go index 467fd33..cdf4c68 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -50,7 +50,13 @@ type User struct { Role string } -// Phase 5: Backup Structure +type CheckRecord struct { + SiteID int + LatencyNs int64 + IsUp bool + CheckedAt time.Time +} + type Backup struct { Sites []Site `json:"sites"` Alerts []AlertConfig `json:"alerts"` diff --git a/internal/monitor/history.go b/internal/monitor/history.go index c6fbcbe..8642255 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -1,6 +1,7 @@ package monitor import ( + "go-upkeep/internal/store" "sync" "time" ) @@ -19,6 +20,31 @@ var ( historyMu sync.RWMutex ) +func InitHistoryFromStore() { + s := store.Get() + if s == nil { + return + } + all := s.LoadAllHistory(maxHistoryLen) + historyMu.Lock() + defer historyMu.Unlock() + for siteID, records := range all { + h := &SiteHistory{} + for _, r := range records { + h.TotalChecks++ + if r.IsUp { + h.UpChecks++ + } + h.Latencies = append(h.Latencies, time.Duration(r.LatencyNs)) + h.Statuses = append(h.Statuses, r.IsUp) + } + histories[siteID] = h + } + if len(all) > 0 { + AddLog("Loaded check history from database") + } +} + func RecordCheck(siteID int, latency time.Duration, isUp bool) { historyMu.Lock() defer historyMu.Unlock() @@ -43,6 +69,10 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) { if len(h.Statuses) > maxHistoryLen { h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] } + + if s := store.Get(); s != nil { + go s.SaveCheck(siteID, latency.Nanoseconds(), isUp) + } } func GetHistory(siteID int) (SiteHistory, bool) { diff --git a/internal/server/server.go b/internal/server/server.go index 89460ed..f814cc3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -185,6 +185,12 @@ func renderStatusPage(w http.ResponseWriter, title string) { + +`)) + type ServerConfig struct { Port int EnableStatus bool @@ -76,7 +205,8 @@ func Start(cfg ServerConfig) { return } if err := store.Get().ImportData(data); err != nil { - http.Error(w, "Import Failed: "+err.Error(), 500) + log.Printf("Import failed: %v", err) + http.Error(w, "Import failed", 500) return } w.Write([]byte("Import Successful")) @@ -94,12 +224,14 @@ func Start(cfg ServerConfig) { } var kb importer.KumaBackup if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { - http.Error(w, "Invalid Kuma JSON: "+err.Error(), 400) + log.Printf("Invalid Kuma JSON: %v", err) + http.Error(w, "Invalid Kuma JSON", 400) return } backup := importer.ConvertKuma(&kb) if err := store.Get().ImportData(backup); err != nil { - http.Error(w, "Import Failed: "+err.Error(), 500) + log.Printf("Kuma import failed: %v", err) + http.Error(w, "Import failed", 500) return } w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) @@ -119,7 +251,9 @@ func Start(cfg ServerConfig) { go func() { addr := fmt.Sprintf(":%d", cfg.Port) fmt.Printf("HTTP Server listening on %s\n", addr) - http.ListenAndServe(addr, mux) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("HTTP server failed: %v", err) + } }() } @@ -143,138 +277,9 @@ func renderStatusPage(w http.ResponseWriter, title string) { return sites[i].Name < sites[j].Name }) - const tpl = ` - - - - {{.Title}} - - - - -
-

{{.Title}}

-
-
-
-
Powered by Go-Upkeep
-
- - - ` - - t, _ := template.New("status").Parse(tpl) data := struct { Title string Sites []models.Site }{Title: title, Sites: sites} - t.Execute(w, data) + statusTpl.Execute(w, data) } diff --git a/internal/store/postgres.go b/internal/store/postgres.go index c5201d0..94c046b 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -239,6 +239,7 @@ func (p *PostgresStore) ImportData(data models.Backup) error { if err != nil { return err } + defer tx.Rollback() tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index e83d96e..1b1d5fd 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -258,8 +258,8 @@ func (s *SQLiteStore) ImportData(data models.Backup) error { if err != nil { return err } + defer tx.Rollback() - // Wipe Existing tx.Exec("DELETE FROM sites") tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") tx.Exec("DELETE FROM alerts") From ab75f61c6bc33b07f54b2815b89aed16251802f3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 00:31:44 -0400 Subject: [PATCH 07/35] refactor(store): unify SQLite and Postgres into dialect-based SQLStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared SQLStore with Dialect interface for the ~5% that differs between backends (DDL, placeholders, sequence resets). - New dialect.go: Dialect interface + placeholder rewriter (? → $N) - New sqlstore.go: single implementation of all 19 Store methods - sqlite.go: reduced from 286 to 83 lines (SQLiteDialect only) - postgres.go: reduced from 266 to 78 lines (PostgresDialect only) - main.go: use NewSQLiteStore/NewPostgresStore constructors Zero CRUD logic duplication. Every future schema change written once. --- cmd/goupkeep/main.go | 11 +- internal/store/dialect.go | 36 +++++ internal/store/postgres.go | 262 +++++---------------------------- internal/store/sqlite.go | 290 ++++++------------------------------- internal/store/sqlstore.go | 243 +++++++++++++++++++++++++++++++ 5 files changed, 368 insertions(+), 474 deletions(-) create mode 100644 internal/store/dialect.go create mode 100644 internal/store/sqlstore.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 3b7d85d..cfd5c2f 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -80,16 +80,21 @@ func main() { flag.Parse() var s store.Store + var dbErr error if *flagDBType == "postgres" { - s = &store.PostgresStore{ConnStr: *flagDSN} + s, dbErr = store.NewPostgresStore(*flagDSN) fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) } else { - s = &store.SQLiteStore{DBPath: *flagDSN} + s, dbErr = store.NewSQLiteStore(*flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN) } + if dbErr != nil { + fmt.Printf("Database connection error: %v\n", dbErr) + os.Exit(1) + } if err := s.Init(); err != nil { - fmt.Printf("Database Init Error: %v\n", err) + fmt.Printf("Database init error: %v\n", err) os.Exit(1) } store.SetGlobal(s) diff --git a/internal/store/dialect.go b/internal/store/dialect.go new file mode 100644 index 0000000..4e1ba04 --- /dev/null +++ b/internal/store/dialect.go @@ -0,0 +1,36 @@ +package store + +import "database/sql" + +type Dialect interface { + DriverName() string + CreateTablesSQL() []string + MigrationsSQL() []string + BoolFalse() string + ResetSequenceOnEmpty(db *sql.DB, table string) + ImportWipe(tx *sql.Tx) + ImportResetSequences(tx *sql.Tx) +} + +// rewritePlaceholders converts ? markers to $1, $2, etc. for Postgres. +// For SQLite (or any dialect not needing rewrite), returns the input unchanged. +func rewritePlaceholders(query string, dollarStyle bool) string { + if !dollarStyle { + return query + } + buf := make([]byte, 0, len(query)+32) + n := 0 + for i := 0; i < len(query); i++ { + if query[i] == '?' { + n++ + buf = append(buf, '$') + if n >= 10 { + buf = append(buf, byte('0'+n/10)) + } + buf = append(buf, byte('0'+n%10)) + } else { + buf = append(buf, query[i]) + } + } + return string(buf) +} diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 94c046b..78fcc8d 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -2,77 +2,53 @@ package store import ( "database/sql" - "encoding/json" - "go-upkeep/internal/models" _ "github.com/lib/pq" ) -type PostgresStore struct { - ConnStr string - db *sql.DB +type PostgresDialect struct{} + +func NewPostgresStore(connStr string) (*SQLStore, error) { + return NewSQLStore("postgres", connStr, &PostgresDialect{}) } -func (p *PostgresStore) Init() error { - var err error - p.db, err = sql.Open("postgres", p.ConnStr) - if err != nil { - return err - } +func (d *PostgresDialect) DriverName() string { return "postgres" } +func (d *PostgresDialect) BoolFalse() string { return "FALSE" } - queries := []string{ +func (d *PostgresDialect) CreateTablesSQL() []string { + return []string{ `CREATE TABLE IF NOT EXISTS alerts ( id SERIAL PRIMARY KEY, - name TEXT, - type TEXT, - settings TEXT - );`, + name TEXT, type TEXT, settings TEXT + )`, `CREATE TABLE IF NOT EXISTS sites ( id SERIAL PRIMARY KEY, - name TEXT DEFAULT 'New Monitor', - url TEXT, - type TEXT DEFAULT 'http', - token TEXT, - interval INTEGER, - alert_id INTEGER, - check_ssl BOOLEAN DEFAULT FALSE, - threshold INTEGER DEFAULT 7, - max_retries INTEGER DEFAULT 0, - hostname TEXT DEFAULT '', - port INTEGER DEFAULT 0, - timeout INTEGER DEFAULT 0, - method TEXT DEFAULT 'GET', - description TEXT DEFAULT '', - parent_id INTEGER DEFAULT 0, - accepted_codes TEXT DEFAULT '200-299', - dns_resolve_type TEXT DEFAULT '', - dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT FALSE, - paused BOOLEAN DEFAULT FALSE - );`, + name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http', + token TEXT, interval INTEGER, alert_id INTEGER, + check_ssl BOOLEAN DEFAULT FALSE, threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0, hostname TEXT DEFAULT '', + port INTEGER DEFAULT 0, timeout INTEGER DEFAULT 0, + method TEXT DEFAULT 'GET', description TEXT DEFAULT '', + parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', + dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', + ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE + )`, `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - username TEXT NOT NULL, - public_key TEXT NOT NULL, + username TEXT NOT NULL, public_key TEXT NOT NULL, role TEXT DEFAULT 'user' - );`, + )`, `CREATE TABLE IF NOT EXISTS check_history ( id SERIAL PRIMARY KEY, - site_id INTEGER NOT NULL, - latency_ns BIGINT, - is_up BOOLEAN, - checked_at TIMESTAMP DEFAULT NOW() - );`, - } - for _, q := range queries { - if _, err := p.db.Exec(q); err != nil { - return err - } + site_id INTEGER NOT NULL, latency_ns BIGINT, + is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, } +} - p.db.Exec("CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)") - - migrations := []string{ +func (d *PostgresDialect) MigrationsSQL() []string { + return []string{ "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0", @@ -85,182 +61,18 @@ func (p *PostgresStore) Init() error { "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", } - for _, m := range migrations { - p.db.Exec(m) - } - - return nil } -// ... [CRUD Methods are identical to Phase 4, keeping them concise here] ... -func (p *PostgresStore) GetSites() []models.Site { - rows, err := p.db.Query("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, FALSE), COALESCE(paused, FALSE) FROM sites") - if err != nil { - return []models.Site{} - } - defer rows.Close() - var sites []models.Site - for rows.Next() { - var s models.Site - rows.Scan(&s.ID, &s.Name, &s.URL, &s.Type, &s.Token, &s.Interval, &s.AlertID, &s.CheckSSL, &s.ExpiryThreshold, &s.MaxRetries, - &s.Hostname, &s.Port, &s.Timeout, &s.Method, &s.Description, &s.ParentID, &s.AcceptedCodes, &s.DNSResolveType, &s.DNSServer, &s.IgnoreTLS, &s.Paused) - sites = append(sites, s) - } - return sites -} -func (p *PostgresStore) AddSite(site models.Site) { - token := "" - if site.Type == "push" { - token = generateToken() - } - p.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", - site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) -} -func (p *PostgresStore) UpdateSite(site models.Site) { - var existingToken string - p.db.QueryRow("SELECT token FROM sites WHERE id=$1", site.ID).Scan(&existingToken) - if site.Type == "push" && existingToken == "" { - existingToken = generateToken() - } - p.db.Exec("UPDATE sites SET name=$1, url=$2, type=$3, token=$4, interval=$5, alert_id=$6, check_ssl=$7, threshold=$8, max_retries=$9, hostname=$10, port=$11, timeout=$12, method=$13, description=$14, parent_id=$15, accepted_codes=$16, dns_resolve_type=$17, dns_server=$18, ignore_tls=$19, paused=$20 WHERE id=$21", - site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) -} -func (p *PostgresStore) UpdateSitePaused(id int, paused bool) { - p.db.Exec("UPDATE sites SET paused=$1 WHERE id=$2", paused, id) -} -func (p *PostgresStore) DeleteSite(id int) { p.db.Exec("DELETE FROM sites WHERE id=$1", id) } -func (p *PostgresStore) GetAllAlerts() []models.AlertConfig { - rows, err := p.db.Query("SELECT id, name, type, settings FROM alerts") - if err != nil { - return []models.AlertConfig{} - } - defer rows.Close() - var alerts []models.AlertConfig - for rows.Next() { - var a models.AlertConfig - var settingsJSON string - rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - json.Unmarshal([]byte(settingsJSON), &a.Settings) - alerts = append(alerts, a) - } - return alerts -} -func (p *PostgresStore) GetAlert(id int) (models.AlertConfig, bool) { - var a models.AlertConfig - var settingsJSON string - err := p.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = $1", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - if err != nil { - return a, false - } - json.Unmarshal([]byte(settingsJSON), &a.Settings) - return a, true -} -func (p *PostgresStore) AddAlert(name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - p.db.Exec("INSERT INTO alerts (name, type, settings) VALUES ($1, $2, $3)", name, aType, string(jsonBytes)) -} -func (p *PostgresStore) UpdateAlert(id int, name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - p.db.Exec("UPDATE alerts SET name=$1, type=$2, settings=$3 WHERE id=$4", name, aType, string(jsonBytes), id) -} -func (p *PostgresStore) DeleteAlert(id int) { p.db.Exec("DELETE FROM alerts WHERE id=$1", id) } -func (p *PostgresStore) GetAllUsers() []models.User { - rows, err := p.db.Query("SELECT id, username, public_key, role FROM users") - if err != nil { - return []models.User{} - } - defer rows.Close() - var users []models.User - for rows.Next() { - var u models.User - rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role) - users = append(users, u) - } - return users -} -func (p *PostgresStore) AddUser(username, publicKey, role string) error { - _, err := p.db.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", username, publicKey, role) - return err -} -func (p *PostgresStore) UpdateUser(id int, username, publicKey, role string) error { - _, err := p.db.Exec("UPDATE users SET username=$1, public_key=$2, role=$3 WHERE id=$4", username, publicKey, role, id) - return err -} -func (p *PostgresStore) DeleteUser(id int) error { - _, err := p.db.Exec("DELETE FROM users WHERE id=$1", id) - return err -} - -func (p *PostgresStore) SaveCheck(siteID int, latencyNs int64, isUp bool) { - p.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES ($1, $2, $3)", siteID, latencyNs, isUp) - p.db.Exec(`DELETE FROM check_history WHERE site_id = $1 AND id NOT IN ( - SELECT id FROM check_history WHERE site_id = $1 ORDER BY checked_at DESC LIMIT 1000 - )`, siteID) -} - -func (p *PostgresStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { - result := make(map[int][]models.CheckRecord) - rows, err := p.db.Query(` - SELECT site_id, latency_ns, is_up FROM ( - SELECT site_id, latency_ns, is_up, - ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn - FROM check_history - ) sub WHERE rn <= $1`, limit) - if err != nil { - return result - } - defer rows.Close() - for rows.Next() { - var r models.CheckRecord - rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp) - result[r.SiteID] = append(result[r.SiteID], r) - } - for id, records := range result { - for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { - records[i], records[j] = records[j], records[i] - } - result[id] = records - } - return result -} - -func (p *PostgresStore) ExportData() models.Backup { - return models.Backup{ - Sites: p.GetSites(), - Alerts: p.GetAllAlerts(), - Users: p.GetAllUsers(), - } -} - -func (p *PostgresStore) ImportData(data models.Backup) error { - tx, err := p.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() +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") - - for _, u := range data.Users { - tx.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", u.Username, u.PublicKey, u.Role) - } - for _, a := range data.Alerts { - jsonBytes, _ := json.Marshal(a.Settings) - tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES ($1, $2, $3, $4)", a.ID, a.Name, a.Type, string(jsonBytes)) - } - for _, st := range data.Sites { - tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", - 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, st.DNSServer, st.IgnoreTLS, st.Paused) - } - - tx.Exec("SELECT setval('sites_id_seq', (SELECT MAX(id) FROM sites))") - tx.Exec("SELECT setval('alerts_id_seq', (SELECT MAX(id) FROM alerts))") - tx.Exec("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))") - - return tx.Commit() +} + +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))") } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 1b1d5fd..dbeb74d 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -1,77 +1,54 @@ package store import ( - "crypto/rand" "database/sql" - "encoding/hex" - "encoding/json" - "go-upkeep/internal/models" _ "github.com/mattn/go-sqlite3" ) -type SQLiteStore struct { - DBPath string - db *sql.DB +type SQLiteDialect struct{} + +func NewSQLiteStore(path string) (*SQLStore, error) { + return NewSQLStore("sqlite3", path, &SQLiteDialect{}) } -func (s *SQLiteStore) Init() error { - var err error - s.db, err = sql.Open("sqlite3", s.DBPath) - if err != nil { - return err - } +func (d *SQLiteDialect) DriverName() string { return "sqlite3" } +func (d *SQLiteDialect) BoolFalse() string { return "0" } - createTables := ` - CREATE TABLE IF NOT EXISTS alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - type TEXT, - settings TEXT - ); - CREATE TABLE IF NOT EXISTS sites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT DEFAULT 'New Monitor', - url TEXT, - type TEXT DEFAULT 'http', - token TEXT, - interval INTEGER, - alert_id INTEGER, - check_ssl BOOLEAN DEFAULT 0, - threshold INTEGER DEFAULT 7, - max_retries INTEGER DEFAULT 0, - hostname TEXT DEFAULT '', - port INTEGER DEFAULT 0, - timeout INTEGER DEFAULT 0, - method TEXT DEFAULT 'GET', - description TEXT DEFAULT '', - parent_id INTEGER DEFAULT 0, - accepted_codes TEXT DEFAULT '200-299', - dns_resolve_type TEXT DEFAULT '', - dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT 0, - paused BOOLEAN DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - public_key TEXT NOT NULL, - role TEXT DEFAULT 'user' - ); - CREATE TABLE IF NOT EXISTS check_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - site_id INTEGER NOT NULL, - latency_ns INTEGER, - is_up BOOLEAN, - checked_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC);` - _, err = s.db.Exec(createTables) - if err != nil { - return err +func (d *SQLiteDialect) CreateTablesSQL() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, type TEXT, settings TEXT + )`, + `CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http', + token TEXT, interval INTEGER, alert_id INTEGER, + check_ssl BOOLEAN DEFAULT 0, threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0, hostname TEXT DEFAULT '', + port INTEGER DEFAULT 0, timeout INTEGER DEFAULT 0, + method TEXT DEFAULT 'GET', description TEXT DEFAULT '', + parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', + dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', + ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, public_key TEXT NOT NULL, + role TEXT DEFAULT 'user' + )`, + `CREATE TABLE IF NOT EXISTS check_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, latency_ns INTEGER, + is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, } +} - migrations := []string{ +func (d *SQLiteDialect) MigrationsSQL() []string { + return []string{ "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0", @@ -84,202 +61,23 @@ func (s *SQLiteStore) Init() error { "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", } - for _, m := range migrations { - s.db.Exec(m) - } - - return nil } -func generateToken() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - panic("crypto/rand failed: " + err.Error()) - } - return hex.EncodeToString(b) -} - -func (s *SQLiteStore) GetSites() []models.Site { - rows, err := s.db.Query("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, 0), COALESCE(paused, 0) FROM sites") - if err != nil { - return []models.Site{} - } - defer rows.Close() - var sites []models.Site - for rows.Next() { - var st models.Site - 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, &st.DNSServer, &st.IgnoreTLS, &st.Paused) - sites = append(sites, st) - } - return sites -} -func (s *SQLiteStore) AddSite(site models.Site) { - token := "" - if site.Type == "push" { - token = generateToken() - } - s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) -} -func (s *SQLiteStore) UpdateSite(site models.Site) { - var existingToken string - s.db.QueryRow("SELECT token FROM sites WHERE id=?", site.ID).Scan(&existingToken) - if site.Type == "push" && existingToken == "" { - existingToken = generateToken() - } - s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?", - site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) -} -func (s *SQLiteStore) UpdateSitePaused(id int, paused bool) { - s.db.Exec("UPDATE sites SET paused=? WHERE id=?", paused, id) -} -func (s *SQLiteStore) DeleteSite(id int) { - s.db.Exec("DELETE FROM sites WHERE id=?", id) +func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { var count int - s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count) + db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) if count == 0 { - s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") - } -} -func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig { - rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") - if err != nil { - return []models.AlertConfig{} - } - defer rows.Close() - var alerts []models.AlertConfig - for rows.Next() { - var a models.AlertConfig - var settingsJSON string - rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - json.Unmarshal([]byte(settingsJSON), &a.Settings) - alerts = append(alerts, a) - } - return alerts -} -func (s *SQLiteStore) GetAlert(id int) (models.AlertConfig, bool) { - var a models.AlertConfig - var settingsJSON string - err := s.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = ?", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - if err != nil { - return a, false - } - json.Unmarshal([]byte(settingsJSON), &a.Settings) - return a, true -} -func (s *SQLiteStore) AddAlert(name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - s.db.Exec("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)", name, aType, string(jsonBytes)) -} -func (s *SQLiteStore) UpdateAlert(id int, name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - s.db.Exec("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?", name, aType, string(jsonBytes), id) -} -func (s *SQLiteStore) DeleteAlert(id int) { - s.db.Exec("DELETE FROM alerts WHERE id=?", id) - var count int - s.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&count) - if count == 0 { - s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") - } -} -func (s *SQLiteStore) GetAllUsers() []models.User { - rows, err := s.db.Query("SELECT id, username, public_key, role FROM users") - if err != nil { - return []models.User{} - } - defer rows.Close() - var users []models.User - for rows.Next() { - var u models.User - rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role) - users = append(users, u) - } - return users -} -func (s *SQLiteStore) AddUser(username, publicKey, role string) error { - _, err := s.db.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", username, publicKey, role) - return err -} -func (s *SQLiteStore) UpdateUser(id int, username, publicKey, role string) error { - _, err := s.db.Exec("UPDATE users SET username=?, public_key=?, role=? WHERE id=?", username, publicKey, role, id) - return err -} -func (s *SQLiteStore) DeleteUser(id int) error { - _, err := s.db.Exec("DELETE FROM users WHERE id=?", id) - return err -} - -func (s *SQLiteStore) SaveCheck(siteID int, latencyNs int64, isUp bool) { - s.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)", siteID, latencyNs, isUp) - s.db.Exec(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( - SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 - )`, siteID, siteID) -} - -func (s *SQLiteStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { - result := make(map[int][]models.CheckRecord) - rows, err := s.db.Query(` - SELECT site_id, latency_ns, is_up FROM ( - SELECT site_id, latency_ns, is_up, - ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn - FROM check_history - ) WHERE rn <= ?`, limit) - if err != nil { - return result - } - defer rows.Close() - for rows.Next() { - var r models.CheckRecord - rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp) - result[r.SiteID] = append(result[r.SiteID], r) - } - for id, records := range result { - for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { - records[i], records[j] = records[j], records[i] - } - result[id] = records - } - return result -} - -func (s *SQLiteStore) ExportData() models.Backup { - return models.Backup{ - Sites: s.GetSites(), - Alerts: s.GetAllAlerts(), - Users: s.GetAllUsers(), + db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table) } } -func (s *SQLiteStore) ImportData(data models.Backup) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - +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'") - - // Insert New - for _, u := range data.Users { - tx.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", u.Username, u.PublicKey, u.Role) - } - for _, a := range data.Alerts { - jsonBytes, _ := json.Marshal(a.Settings) - tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)", a.ID, a.Name, a.Type, string(jsonBytes)) - } - for _, st := range data.Sites { - tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - 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, st.DNSServer, st.IgnoreTLS, st.Paused) - } - - return tx.Commit() } + +func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {} diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go new file mode 100644 index 0000000..a715a02 --- /dev/null +++ b/internal/store/sqlstore.go @@ -0,0 +1,243 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "go-upkeep/internal/models" +) + +type SQLStore struct { + db *sql.DB + dialect Dialect + dollar bool +} + +func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) { + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, err + } + _, isDollar := dialect.(*PostgresDialect) + return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil +} + +func (s *SQLStore) q(query string) string { + return rewritePlaceholders(query, s.dollar) +} + +func generateToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +func (s *SQLStore) Init() error { + for _, stmt := range s.dialect.CreateTablesSQL() { + if _, err := s.db.Exec(stmt); err != nil { + return err + } + } + for _, m := range s.dialect.MigrationsSQL() { + s.db.Exec(m) + } + return nil +} + +func (s *SQLStore) GetSites() []models.Site { + bf := s.dialect.BoolFalse() + query := fmt.Sprintf( + "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) FROM sites", + bf, bf, + ) + rows, err := s.db.Query(query) + if err != nil { + return []models.Site{} + } + defer rows.Close() + var sites []models.Site + for rows.Next() { + var st models.Site + 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, + &st.DNSServer, &st.IgnoreTLS, &st.Paused) + sites = append(sites, st) + } + return sites +} + +func (s *SQLStore) AddSite(site models.Site) { + token := "" + if site.Type == "push" { + token = generateToken() + } + s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) +} + +func (s *SQLStore) UpdateSite(site models.Site) { + var existingToken string + s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) + if site.Type == "push" && existingToken == "" { + existingToken = generateToken() + } + s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?"), + site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) +} + +func (s *SQLStore) UpdateSitePaused(id int, paused bool) { + s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id) +} + +func (s *SQLStore) DeleteSite(id int) { + s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id) + s.dialect.ResetSequenceOnEmpty(s.db, "sites") +} + +func (s *SQLStore) GetAllAlerts() []models.AlertConfig { + rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") + if err != nil { + return []models.AlertConfig{} + } + defer rows.Close() + var alerts []models.AlertConfig + for rows.Next() { + var a models.AlertConfig + var settingsJSON string + rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + json.Unmarshal([]byte(settingsJSON), &a.Settings) + alerts = append(alerts, a) + } + return alerts +} + +func (s *SQLStore) GetAlert(id int) (models.AlertConfig, bool) { + var a models.AlertConfig + var settingsJSON string + err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + if err != nil { + return a, false + } + json.Unmarshal([]byte(settingsJSON), &a.Settings) + return a, true +} + +func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) { + jsonBytes, _ := json.Marshal(settings) + s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes)) +} + +func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) { + jsonBytes, _ := json.Marshal(settings) + s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id) +} + +func (s *SQLStore) DeleteAlert(id int) { + s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id) + s.dialect.ResetSequenceOnEmpty(s.db, "alerts") +} + +func (s *SQLStore) GetAllUsers() []models.User { + rows, err := s.db.Query("SELECT id, username, public_key, role FROM users") + if err != nil { + return []models.User{} + } + defer rows.Close() + var users []models.User + for rows.Next() { + var u models.User + rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role) + users = append(users, u) + } + return users +} + +func (s *SQLStore) AddUser(username, publicKey, role string) error { + _, err := s.db.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role) + return err +} + +func (s *SQLStore) UpdateUser(id int, username, publicKey, role string) error { + _, err := s.db.Exec(s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id) + return err +} + +func (s *SQLStore) DeleteUser(id int) error { + _, err := s.db.Exec(s.q("DELETE FROM users WHERE id=?"), id) + return err +} + +func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) { + s.db.Exec(s.q("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)"), siteID, latencyNs, isUp) + s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( + SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 + )`), siteID, siteID) +} + +func (s *SQLStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { + result := make(map[int][]models.CheckRecord) + rows, err := s.db.Query(s.q(` + SELECT site_id, latency_ns, is_up FROM ( + SELECT site_id, latency_ns, is_up, + ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn + FROM check_history + ) sub WHERE rn <= ?`), limit) + if err != nil { + return result + } + defer rows.Close() + for rows.Next() { + var r models.CheckRecord + rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp) + result[r.SiteID] = append(result[r.SiteID], r) + } + for id, records := range result { + for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { + records[i], records[j] = records[j], records[i] + } + result[id] = records + } + return result +} + +func (s *SQLStore) ExportData() models.Backup { + return models.Backup{ + Sites: s.GetSites(), + Alerts: s.GetAllAlerts(), + Users: s.GetAllUsers(), + } +} + +func (s *SQLStore) ImportData(data models.Backup) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + s.dialect.ImportWipe(tx) + + for _, u := range data.Users { + tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role) + } + for _, a := range data.Alerts { + jsonBytes, _ := json.Marshal(a.Settings) + tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)) + } + for _, st := range data.Sites { + tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + 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, st.DNSServer, st.IgnoreTLS, st.Paused) + } + + s.dialect.ImportResetSequences(tx) + + return tx.Commit() +} From d4f4012c8a812d4399835ffead72463c1f66f3f5 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 00:37:20 -0400 Subject: [PATCH 08/35] refactor(store): add error returns to all Store interface methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Store method now returns an error. Callers handle errors gracefully — TUI logs to event log, server returns HTTP 500, monitor engine logs and retries. All rows.Scan() errors are now checked in sqlstore.go instead of silently appending corrupt data. - GetSites, GetAllAlerts, GetAllUsers return ([]T, error) - GetAlert returns (AlertConfig, error) instead of (AlertConfig, bool) - AddSite, UpdateSite, DeleteSite, etc. all return error - SaveCheck, LoadAllHistory, ExportData return error - ~25 caller sites updated across tui, server, monitor, main --- cmd/goupkeep/main.go | 10 ++- internal/monitor/history.go | 8 +- internal/monitor/monitor.go | 11 ++- internal/server/server.go | 7 +- internal/store/sqlstore.go | 146 ++++++++++++++++++++++++------------ internal/store/store.go | 28 +++---- internal/tui/tab_alerts.go | 9 ++- internal/tui/tab_sites.go | 22 ++++-- internal/tui/tab_users.go | 9 ++- internal/tui/tui.go | 28 ++++--- 10 files changed, 185 insertions(+), 93 deletions(-) diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index cfd5c2f..77cdadd 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -174,7 +174,8 @@ func startSSHServer(port int) { } func seedDemoData(s store.Store) { - if existing := s.GetSites(); len(existing) > 0 { + existing, _ := s.GetSites() + if len(existing) > 0 { return } fmt.Println("Seeding demo data...") @@ -187,7 +188,7 @@ func seedDemoData(s store.Store) { "from": "oncall@example.com", "to": "team@example.com", }) - alerts := s.GetAllAlerts() + alerts, _ := s.GetAllAlerts() alertID := 0 if len(alerts) > 0 { alertID = alerts[0].ID @@ -206,7 +207,10 @@ func seedDemoData(s store.Store) { } func isKeyAllowed(incomingKey ssh.PublicKey) bool { - users := store.Get().GetAllUsers() + users, err := store.Get().GetAllUsers() + if err != nil { + return false + } for _, u := range users { allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) if err != nil { diff --git a/internal/monitor/history.go b/internal/monitor/history.go index 8642255..43ef0ee 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -25,7 +25,11 @@ func InitHistoryFromStore() { if s == nil { return } - all := s.LoadAllHistory(maxHistoryLen) + all, err := s.LoadAllHistory(maxHistoryLen) + if err != nil { + AddLog("Failed to load check history: " + err.Error()) + return + } historyMu.Lock() defer historyMu.Unlock() for siteID, records := range all { @@ -71,7 +75,7 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) { } if s := store.Get(); s != nil { - go s.SaveCheck(siteID, latency.Nanoseconds(), isUp) + go func() { _ = s.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() } } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 3f2a869..27925ed 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -128,7 +128,12 @@ func StartEngine() { continue } - sites := s_instance.GetSites() + sites, err := s_instance.GetSites() + if err != nil { + AddLog(fmt.Sprintf("Failed to load sites: %v", err)) + time.Sleep(5 * time.Second) + continue + } for _, s := range sites { Mutex.RLock() _, exists := LiveState[s.ID] @@ -406,8 +411,8 @@ func triggerAlert(alertID int, title, message string) { if s_instance == nil { return } - cfg, ok := s_instance.GetAlert(alertID) - if !ok { + cfg, err := s_instance.GetAlert(alertID) + if err != nil { return } provider := alert.GetProvider(cfg) diff --git a/internal/server/server.go b/internal/server/server.go index 3cf6228..bb97a88 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -185,7 +185,12 @@ func Start(cfg ServerConfig) { http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) return } - data := store.Get().ExportData() + data, err := store.Get().ExportData() + if err != nil { + log.Printf("Export failed: %v", err) + http.Error(w, "Export failed", 500) + return + } json.NewEncoder(w).Encode(data) }) diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index a715a02..3142399 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -48,7 +48,7 @@ func (s *SQLStore) Init() error { return nil } -func (s *SQLStore) GetSites() []models.Site { +func (s *SQLStore) GetSites() ([]models.Site, error) { bf := s.dialect.BoolFalse() query := fmt.Sprintf( "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) FROM sites", @@ -56,107 +56,132 @@ func (s *SQLStore) GetSites() []models.Site { ) rows, err := s.db.Query(query) if err != nil { - return []models.Site{} + return nil, err } defer rows.Close() var sites []models.Site for rows.Next() { var st models.Site - rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, + 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, - &st.DNSServer, &st.IgnoreTLS, &st.Paused) + &st.DNSServer, &st.IgnoreTLS, &st.Paused); err != nil { + return sites, err + } sites = append(sites, st) } - return sites + return sites, rows.Err() } -func (s *SQLStore) AddSite(site models.Site) { +func (s *SQLStore) AddSite(site models.Site) error { token := "" if site.Type == "push" { token = generateToken() } - s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + _, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) + return err } -func (s *SQLStore) UpdateSite(site models.Site) { +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) if site.Type == "push" && existingToken == "" { existingToken = generateToken() } - s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?"), + _, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?"), site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) + return err } -func (s *SQLStore) UpdateSitePaused(id int, paused bool) { - s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id) +func (s *SQLStore) UpdateSitePaused(id int, paused bool) error { + _, err := s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id) + return err } -func (s *SQLStore) DeleteSite(id int) { - s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id) +func (s *SQLStore) DeleteSite(id int) error { + _, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id) + if err != nil { + return err + } s.dialect.ResetSequenceOnEmpty(s.db, "sites") + return nil } -func (s *SQLStore) GetAllAlerts() []models.AlertConfig { +func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) { rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") if err != nil { - return []models.AlertConfig{} + return nil, err } defer rows.Close() var alerts []models.AlertConfig for rows.Next() { var a models.AlertConfig var settingsJSON string - rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil { + return alerts, err + } json.Unmarshal([]byte(settingsJSON), &a.Settings) alerts = append(alerts, a) } - return alerts + return alerts, rows.Err() } -func (s *SQLStore) GetAlert(id int) (models.AlertConfig, bool) { +func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) { var a models.AlertConfig var settingsJSON string err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) if err != nil { - return a, false + return a, err } json.Unmarshal([]byte(settingsJSON), &a.Settings) - return a, true + return a, nil } -func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes)) +func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error { + jsonBytes, err := json.Marshal(settings) + if err != nil { + return err + } + _, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes)) + return err } -func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) { - jsonBytes, _ := json.Marshal(settings) - s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id) +func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error { + jsonBytes, err := json.Marshal(settings) + if err != nil { + return err + } + _, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id) + return err } -func (s *SQLStore) DeleteAlert(id int) { - s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id) +func (s *SQLStore) DeleteAlert(id int) error { + _, err := s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id) + if err != nil { + return err + } s.dialect.ResetSequenceOnEmpty(s.db, "alerts") + return nil } -func (s *SQLStore) GetAllUsers() []models.User { +func (s *SQLStore) GetAllUsers() ([]models.User, error) { rows, err := s.db.Query("SELECT id, username, public_key, role FROM users") if err != nil { - return []models.User{} + return nil, err } defer rows.Close() var users []models.User for rows.Next() { var u models.User - rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role) + if err := rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role); err != nil { + return users, err + } users = append(users, u) } - return users + return users, rows.Err() } func (s *SQLStore) AddUser(username, publicKey, role string) error { @@ -174,14 +199,18 @@ func (s *SQLStore) DeleteUser(id int) error { return err } -func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) { - s.db.Exec(s.q("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)"), siteID, latencyNs, isUp) - s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( +func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error { + _, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)"), siteID, latencyNs, isUp) + if err != nil { + return err + } + _, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 )`), siteID, siteID) + return err } -func (s *SQLStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { +func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) { result := make(map[int][]models.CheckRecord) rows, err := s.db.Query(s.q(` SELECT site_id, latency_ns, is_up FROM ( @@ -190,12 +219,14 @@ func (s *SQLStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { FROM check_history ) sub WHERE rn <= ?`), limit) if err != nil { - return result + return result, err } defer rows.Close() for rows.Next() { var r models.CheckRecord - rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp) + if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil { + return result, err + } result[r.SiteID] = append(result[r.SiteID], r) } for id, records := range result { @@ -204,15 +235,23 @@ func (s *SQLStore) LoadAllHistory(limit int) map[int][]models.CheckRecord { } result[id] = records } - return result + return result, rows.Err() } -func (s *SQLStore) ExportData() models.Backup { - return models.Backup{ - Sites: s.GetSites(), - Alerts: s.GetAllAlerts(), - Users: s.GetAllUsers(), +func (s *SQLStore) ExportData() (models.Backup, error) { + sites, err := s.GetSites() + if err != nil { + return models.Backup{}, err } + alerts, err := s.GetAllAlerts() + if err != nil { + return models.Backup{}, err + } + users, err := s.GetAllUsers() + if err != nil { + return models.Backup{}, err + } + return models.Backup{Sites: sites, Alerts: alerts, Users: users}, nil } func (s *SQLStore) ImportData(data models.Backup) error { @@ -225,16 +264,25 @@ func (s *SQLStore) ImportData(data models.Backup) error { s.dialect.ImportWipe(tx) for _, u := range data.Users { - tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role) + if _, err := tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil { + return err + } } for _, a := range data.Alerts { - jsonBytes, _ := json.Marshal(a.Settings) - tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)) + jsonBytes, err := json.Marshal(a.Settings) + if err != nil { + return err + } + if _, err := tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)); err != nil { + return err + } } for _, st := range data.Sites { - tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), 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, st.DNSServer, st.IgnoreTLS, st.Paused) + st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused); err != nil { + return err + } } s.dialect.ImportResetSequences(tx) diff --git a/internal/store/store.go b/internal/store/store.go index af3cd71..d119597 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,31 +8,31 @@ type Store interface { Init() error // Sites - GetSites() []models.Site - AddSite(site models.Site) - UpdateSite(site models.Site) - UpdateSitePaused(id int, paused bool) - DeleteSite(id int) + GetSites() ([]models.Site, error) + AddSite(site models.Site) error + UpdateSite(site models.Site) error + UpdateSitePaused(id int, paused bool) error + DeleteSite(id int) error // Alerts - GetAllAlerts() []models.AlertConfig - GetAlert(id int) (models.AlertConfig, bool) - AddAlert(name, aType string, settings map[string]string) - UpdateAlert(id int, name, aType string, settings map[string]string) - DeleteAlert(id int) + GetAllAlerts() ([]models.AlertConfig, error) + GetAlert(id int) (models.AlertConfig, error) + AddAlert(name, aType string, settings map[string]string) error + UpdateAlert(id int, name, aType string, settings map[string]string) error + DeleteAlert(id int) error // Users - GetAllUsers() []models.User + GetAllUsers() ([]models.User, error) AddUser(username, publicKey, role string) error UpdateUser(id int, username, publicKey, role string) error DeleteUser(id int) error // History - SaveCheck(siteID int, latencyNs int64, isUp bool) - LoadAllHistory(limit int) map[int][]models.CheckRecord + SaveCheck(siteID int, latencyNs int64, isUp bool) error + LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) // Backup & Restore - ExportData() models.Backup + ExportData() (models.Backup, error) ImportData(data models.Backup) error } diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 8a0447b..72bd19d 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "go-upkeep/internal/monitor" "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" @@ -277,9 +278,13 @@ func (m *Model) submitAlertForm() { } if m.editID > 0 { - store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings) + if err := store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { + monitor.AddLog("Update alert failed: " + err.Error()) + } } else { - store.Get().AddAlert(d.Name, d.AlertType, settings) + if err := store.Get().AddAlert(d.Name, d.AlertType, settings); err != nil { + monitor.AddLog("Add alert failed: " + err.Error()) + } } m.state = stateDashboard } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 2656613..f1a2aa0 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -361,12 +361,14 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} - if store.Get() != nil { - for _, a := range store.Get().GetAllAlerts() { - alertOpts = append(alertOpts, huh.NewOption( - fmt.Sprintf("%s (%s)", a.Name, a.Type), - strconv.Itoa(a.ID), - )) + if s := store.Get(); s != nil { + if alerts, err := s.GetAllAlerts(); err == nil { + for _, a := range alerts { + alertOpts = append(alertOpts, huh.NewOption( + fmt.Sprintf("%s (%s)", a.Name, a.Type), + strconv.Itoa(a.ID), + )) + } } } @@ -558,10 +560,14 @@ func (m *Model) submitSiteForm() { } if m.editID > 0 { - store.Get().UpdateSite(site) + if err := store.Get().UpdateSite(site); err != nil { + monitor.AddLog("Update site failed: " + err.Error()) + } monitor.UpdateSiteConfig(site) } else { - store.Get().AddSite(site) + if err := store.Get().AddSite(site); err != nil { + monitor.AddLog("Add site failed: " + err.Error()) + } } m.state = stateDashboard } diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 4858b7c..77d4182 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "go-upkeep/internal/monitor" "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" @@ -145,9 +146,13 @@ func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) submitUserForm() { d := m.userFormData if m.editID > 0 { - store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role) + if err := store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { + monitor.AddLog("Update user failed: " + err.Error()) + } } else { - store.Get().AddUser(d.Username, d.PublicKey, d.Role) + if err := store.Get().AddUser(d.Username, d.PublicKey, d.Role); err != nil { + monitor.AddLog("Add user failed: " + err.Error()) + } } m.state = stateUsers } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4972a3a..533c993 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -107,17 +107,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { case "y", "Y": - if store.Get() != nil { + if s := store.Get(); s != nil { switch m.deleteTab { case 0: - store.Get().DeleteSite(m.deleteID) + if err := s.DeleteSite(m.deleteID); err != nil { + monitor.AddLog("Delete site failed: " + err.Error()) + } monitor.RemoveSite(m.deleteID) m.adjustCursor(len(m.sites) - 1) case 1: - store.Get().DeleteAlert(m.deleteID) + if err := s.DeleteAlert(m.deleteID); err != nil { + monitor.AddLog("Delete alert failed: " + err.Error()) + } m.adjustCursor(len(m.alerts) - 1) case 3: - store.Get().DeleteUser(m.deleteID) + if err := s.DeleteUser(m.deleteID); err != nil { + monitor.AddLog("Delete user failed: " + err.Error()) + } m.adjustCursor(len(m.users) - 1) } } @@ -313,8 +319,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { site := m.sites[m.cursor] monitor.ToggleSitePause(site.ID) site.Paused = !site.Paused - if store.Get() != nil { - store.Get().UpdateSitePaused(site.ID, site.Paused) + if s := store.Get(); s != nil { + _ = s.UpdateSitePaused(site.ID, site.Paused) } m.refreshData() } @@ -464,10 +470,14 @@ func (m *Model) refreshData() { } ordered = append(ordered, ungrouped...) m.sites = ordered - if store.Get() != nil { - m.alerts = store.Get().GetAllAlerts() + if s := store.Get(); s != nil { + if alerts, err := s.GetAllAlerts(); err == nil { + m.alerts = alerts + } if m.isAdmin { - m.users = store.Get().GetAllUsers() + if users, err := s.GetAllUsers(); err == nil { + m.users = users + } } } m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) From a6bb9a7affdef583bb8075e63071c0f5c932a096 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 00:45:07 -0400 Subject: [PATCH 09/35] refactor(core): remove store global singleton, thread store explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove store.Get()/SetGlobal()/Current. Store is now passed explicitly to all consumers via constructor parameters and function arguments. - TUI Model holds store field, set via InitialModel(isAdmin, store) - monitor.StartEngine(s) and InitHistoryFromStore(s) accept store - server.Start(cfg, s) closes over store in HTTP handlers - main.go threads store to SSH server, TUI, monitor, server - isKeyAllowed receives store as parameter No more hidden dependency on package-level mutable state in store pkg. Monitor package still uses package-level state (LiveState, etc.) — will be encapsulated into Engine struct in Phase 7. --- cmd/goupkeep/main.go | 22 +++++++------- internal/monitor/history.go | 10 ++----- internal/monitor/monitor.go | 18 +++++------ internal/server/server.go | 8 ++--- internal/store/store.go | 10 ------- internal/tui/tab_alerts.go | 5 ++-- internal/tui/tab_sites.go | 19 +++++------- internal/tui/tab_users.go | 5 ++-- internal/tui/tui.go | 59 ++++++++++++++++--------------------- 9 files changed, 62 insertions(+), 94 deletions(-) diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 77cdadd..921a3c7 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -97,8 +97,6 @@ func main() { fmt.Printf("Database init error: %v\n", err) os.Exit(1) } - store.SetGlobal(s) - if *demo { seedDemoData(s) } @@ -117,15 +115,15 @@ func main() { fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) } - monitor.InitHistoryFromStore() - monitor.StartEngine() + monitor.InitHistoryFromStore(s) + monitor.StartEngine(s) server.Start(server.ServerConfig{ Port: httpPort, EnableStatus: enableStatus, Title: statusTitle, ClusterKey: clusterKey, - }) + }, s) cluster.Start(cluster.Config{ Mode: clusterMode, @@ -133,10 +131,10 @@ func main() { SharedKey: clusterKey, }) - startSSHServer(*port) + startSSHServer(*port, s) if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - p := tea.NewProgram(tui.InitialModel(true), tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(tui.InitialModel(true, s), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Printf("Error: %v\n", err) } @@ -149,16 +147,16 @@ func main() { } } -func startSSHServer(port int) { +func startSSHServer(port int, db store.Store) { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - return isKeyAllowed(key) + return isKeyAllowed(db, key) }), wish.WithMiddleware( bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { - return tui.InitialModel(false), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} + return tui.InitialModel(false, db), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} }), ), ) @@ -206,8 +204,8 @@ func seedDemoData(s store.Store) { s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}) } -func isKeyAllowed(incomingKey ssh.PublicKey) bool { - users, err := store.Get().GetAllUsers() +func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { + users, err := db.GetAllUsers() if err != nil { return false } diff --git a/internal/monitor/history.go b/internal/monitor/history.go index 43ef0ee..dd3f375 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -20,11 +20,7 @@ var ( historyMu sync.RWMutex ) -func InitHistoryFromStore() { - s := store.Get() - if s == nil { - return - } +func InitHistoryFromStore(s store.Store) { all, err := s.LoadAllHistory(maxHistoryLen) if err != nil { AddLog("Failed to load check history: " + err.Error()) @@ -74,8 +70,8 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) { h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] } - if s := store.Get(); s != nil { - go func() { _ = s.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() + if db != nil { + go func() { _ = db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() } } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 27925ed..db11caf 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -55,6 +55,8 @@ var ( insecureSkipVerify bool + db store.Store + strictClient = &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, } @@ -119,16 +121,11 @@ func RecordHeartbeat(token string) bool { return true } -func StartEngine() { +func StartEngine(s store.Store) { + db = s go func() { for { - s_instance := store.Get() - if s_instance == nil { - time.Sleep(1 * time.Second) - continue - } - - sites, err := s_instance.GetSites() + sites, err := db.GetSites() if err != nil { AddLog(fmt.Sprintf("Failed to load sites: %v", err)) time.Sleep(5 * time.Second) @@ -407,11 +404,10 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti } func triggerAlert(alertID int, title, message string) { - s_instance := store.Get() - if s_instance == nil { + if db == nil { return } - cfg, err := s_instance.GetAlert(alertID) + cfg, err := db.GetAlert(alertID) if err != nil { return } diff --git a/internal/server/server.go b/internal/server/server.go index bb97a88..b6a2b52 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -148,7 +148,7 @@ type ServerConfig struct { ClusterKey string // Shared Secret for Security } -func Start(cfg ServerConfig) { +func Start(cfg ServerConfig, s store.Store) { if cfg.ClusterKey == "" { fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") } @@ -185,7 +185,7 @@ func Start(cfg ServerConfig) { http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) return } - data, err := store.Get().ExportData() + data, err := s.ExportData() if err != nil { log.Printf("Export failed: %v", err) http.Error(w, "Export failed", 500) @@ -209,7 +209,7 @@ func Start(cfg ServerConfig) { http.Error(w, "Invalid JSON", 400) return } - if err := store.Get().ImportData(data); err != nil { + if err := s.ImportData(data); err != nil { log.Printf("Import failed: %v", err) http.Error(w, "Import failed", 500) return @@ -234,7 +234,7 @@ func Start(cfg ServerConfig) { return } backup := importer.ConvertKuma(&kb) - if err := store.Get().ImportData(backup); err != nil { + if err := s.ImportData(backup); err != nil { log.Printf("Kuma import failed: %v", err) http.Error(w, "Import failed", 500) return diff --git a/internal/store/store.go b/internal/store/store.go index d119597..35afa0b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,13 +35,3 @@ type Store interface { ExportData() (models.Backup, error) ImportData(data models.Backup) error } - -var Current Store - -func SetGlobal(s Store) { - Current = s -} - -func Get() Store { - return Current -} diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 72bd19d..0d203f1 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "go-upkeep/internal/monitor" - "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -278,11 +277,11 @@ func (m *Model) submitAlertForm() { } if m.editID > 0 { - if err := store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { + if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { monitor.AddLog("Update alert failed: " + err.Error()) } } else { - if err := store.Get().AddAlert(d.Name, d.AlertType, settings); err != nil { + if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil { monitor.AddLog("Add alert failed: " + err.Error()) } } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index f1a2aa0..1644a35 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -4,7 +4,6 @@ import ( "fmt" "go-upkeep/internal/models" "go-upkeep/internal/monitor" - "go-upkeep/internal/store" "net/url" "strconv" "strings" @@ -361,14 +360,12 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} - if s := store.Get(); s != nil { - if alerts, err := s.GetAllAlerts(); err == nil { - for _, a := range alerts { - alertOpts = append(alertOpts, huh.NewOption( - fmt.Sprintf("%s (%s)", a.Name, a.Type), - strconv.Itoa(a.ID), - )) - } + if alerts, err := m.store.GetAllAlerts(); err == nil { + for _, a := range alerts { + alertOpts = append(alertOpts, huh.NewOption( + fmt.Sprintf("%s (%s)", a.Name, a.Type), + strconv.Itoa(a.ID), + )) } } @@ -560,12 +557,12 @@ func (m *Model) submitSiteForm() { } if m.editID > 0 { - if err := store.Get().UpdateSite(site); err != nil { + if err := m.store.UpdateSite(site); err != nil { monitor.AddLog("Update site failed: " + err.Error()) } monitor.UpdateSiteConfig(site) } else { - if err := store.Get().AddSite(site); err != nil { + if err := m.store.AddSite(site); err != nil { monitor.AddLog("Add site failed: " + err.Error()) } } diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 77d4182..d82e5fb 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "go-upkeep/internal/monitor" - "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -146,11 +145,11 @@ func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) submitUserForm() { d := m.userFormData if m.editID > 0 { - if err := store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { + if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { monitor.AddLog("Update user failed: " + err.Error()) } } else { - if err := store.Get().AddUser(d.Username, d.PublicKey, d.Role); err != nil { + if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil { monitor.AddLog("Add user failed: " + err.Error()) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 533c993..4324c65 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -68,6 +68,7 @@ type Model struct { deleteTab int collapsed map[int]bool + store store.Store // harmonica animation state pulseSpring harmonica.Spring @@ -80,7 +81,7 @@ type Model struct { users []models.User } -func InitialModel(isAdmin bool) Model { +func InitialModel(isAdmin bool, s store.Store) Model { vpLogs := viewport.New(100, 20) vpLogs.SetContent("Waiting for logs...") z := zone.New() @@ -90,6 +91,7 @@ func InitialModel(isAdmin bool) Model { logViewport: vpLogs, maxTableRows: 5, isAdmin: isAdmin, + store: s, zones: z, pulseSpring: spring, collapsed: make(map[int]bool), @@ -107,25 +109,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { case "y", "Y": - if s := store.Get(); s != nil { - switch m.deleteTab { - case 0: - if err := s.DeleteSite(m.deleteID); err != nil { - monitor.AddLog("Delete site failed: " + err.Error()) - } - monitor.RemoveSite(m.deleteID) - m.adjustCursor(len(m.sites) - 1) - case 1: - if err := s.DeleteAlert(m.deleteID); err != nil { - monitor.AddLog("Delete alert failed: " + err.Error()) - } - m.adjustCursor(len(m.alerts) - 1) - case 3: - if err := s.DeleteUser(m.deleteID); err != nil { - monitor.AddLog("Delete user failed: " + err.Error()) - } - m.adjustCursor(len(m.users) - 1) + switch m.deleteTab { + case 0: + if err := m.store.DeleteSite(m.deleteID); err != nil { + monitor.AddLog("Delete site failed: " + err.Error()) } + monitor.RemoveSite(m.deleteID) + m.adjustCursor(len(m.sites) - 1) + case 1: + if err := m.store.DeleteAlert(m.deleteID); err != nil { + monitor.AddLog("Delete alert failed: " + err.Error()) + } + m.adjustCursor(len(m.alerts) - 1) + case 3: + if err := m.store.DeleteUser(m.deleteID); err != nil { + monitor.AddLog("Delete user failed: " + err.Error()) + } + m.adjustCursor(len(m.users) - 1) } m.refreshData() m.state = stateDashboard @@ -319,9 +319,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { site := m.sites[m.cursor] monitor.ToggleSitePause(site.ID) site.Paused = !site.Paused - if s := store.Get(); s != nil { - _ = s.UpdateSitePaused(site.ID, site.Paused) - } + _ = m.store.UpdateSitePaused(site.ID, site.Paused) m.refreshData() } case "d", "backspace": @@ -470,23 +468,18 @@ func (m *Model) refreshData() { } ordered = append(ordered, ungrouped...) m.sites = ordered - if s := store.Get(); s != nil { - if alerts, err := s.GetAllAlerts(); err == nil { - m.alerts = alerts - } - if m.isAdmin { - if users, err := s.GetAllUsers(); err == nil { - m.users = users - } + if alerts, err := m.store.GetAllAlerts(); err == nil { + m.alerts = alerts + } + if m.isAdmin { + if users, err := m.store.GetAllUsers(); err == nil { + m.users = users } } m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) } func (m *Model) submitForm() { - if store.Get() == nil { - return - } switch m.state { case stateFormSite: if m.siteFormData != nil { From d6f33a4d1fdc1774a3e18db19e4a5b5e749652e0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 00:46:05 -0400 Subject: [PATCH 10/35] refactor(alert): extract shared HTTPProvider for webhook-based alerts Discord, Slack, and Webhook providers now use a single HTTPProvider struct with a PayloadFunc for the only part that differs. Centralizes response body handling and adds HTTP status code checking (4xx/5xx now return errors instead of being silently ignored). Email and Ntfy keep separate implementations (different protocols). Adding a new HTTP-based alert provider is now a one-line PayloadFunc. --- internal/alert/alert.go | 101 ++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 60 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 16af56c..d67a30d 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -17,15 +17,49 @@ type Provider interface { Send(title, message string) error } +type PayloadFunc func(title, message string) ([]byte, error) + +type HTTPProvider struct { + URL string + Payload PayloadFunc +} + +func (h *HTTPProvider) Send(title, message string) error { + body, err := h.Payload(title, message) + if err != nil { + return err + } + resp, err := alertClient.Post(h.URL, "application/json", bytes.NewBuffer(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("alert webhook returned HTTP %d", resp.StatusCode) + } + return nil +} + +func discordPayload(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)}) +} + +func slackPayload(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)}) +} + +func webhookPayload(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{"title": title, "message": message, "status": "alert"}) +} + func GetProvider(cfg models.AlertConfig) Provider { switch cfg.Type { case "discord": - return &DiscordProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: discordPayload} case "slack": - return &SlackProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: slackPayload} case "webhook": - // Generic Webhook - return &WebhookProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: webhookPayload} case "email": port := "25" if p, ok := cfg.Settings["port"]; ok { @@ -56,62 +90,6 @@ func GetProvider(cfg models.AlertConfig) Provider { } } -// --- DISCORD --- -type DiscordProvider struct{ URL string } - -func (d *DiscordProvider) Send(title, message string) error { - payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)} - jsonValue, err := json.Marshal(payload) - if err != nil { - return err - } - resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -// --- SLACK --- -type SlackProvider struct{ URL string } - -func (s *SlackProvider) Send(title, message string) error { - payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)} - jsonValue, err := json.Marshal(payload) - if err != nil { - return err - } - resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -// --- GENERIC WEBHOOK --- -type WebhookProvider struct{ URL string } - -func (w *WebhookProvider) Send(title, message string) error { - payload := map[string]string{ - "title": title, - "message": message, - "status": "alert", - } - jsonValue, err := json.Marshal(payload) - if err != nil { - return err - } - resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -// --- EMAIL --- type EmailProvider struct { Host, Port, User, Pass, To, From string } @@ -149,5 +127,8 @@ func (n *NtfyProvider) Send(title, message string) error { return err } defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode) + } return nil } From 0e6dc774cb55048112ac6e5bf3690a00469cd96f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 00:49:14 -0400 Subject: [PATCH 11/35] refactor(tui): extract shared table rendering, fix cursor bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New table_helpers.go with renderTable() and shared styles - Remove 4 duplicated style blocks (header/cell/selected/border) from tab_alerts.go and tab_users.go - All 3 tab views now use renderTable() for offset/end calc, selected row highlighting, and table construction - Sites tab keeps siteGroupStyle via StyleOverride callback - Clamp cursor to list length at end of refreshData() to prevent index-out-of-bounds after concurrent list changes - Fix off-by-one in tab click handler (i <= maxTabs → i < tabCount) --- internal/tui/tab_alerts.go | 81 ++++---------- internal/tui/tab_sites.go | 200 +++++++++++++--------------------- internal/tui/tab_users.go | 76 +++---------- internal/tui/table_helpers.go | 75 +++++++++++++ internal/tui/tui.go | 21 +++- 5 files changed, 204 insertions(+), 249 deletions(-) create mode 100644 internal/tui/table_helpers.go diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 0d203f1..60b1890 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -7,25 +7,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" -) - -var ( - alertHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true). - Padding(0, 1) - - alertCellStyle = lipgloss.NewStyle().Padding(0, 1) - - alertSelectedStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#3b3b5c")) - - alertBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) ) type alertFormData struct { @@ -97,49 +78,27 @@ func (m Model) viewAlertsTab() string { return "\n No alert channels configured. Press [n] to add one." } - end := m.tableOffset + m.maxTableRows - if end > len(m.alerts) { - end = len(m.alerts) - } - - selectedVisual := m.cursor - m.tableOffset - - var rows [][]string - for i := m.tableOffset; i < end; i++ { - alert := m.alerts[i] - rows = append(rows, []string{ - fmt.Sprintf("%d", i+1), - m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)), - fmtAlertType(alert.Type), - fmtAlertConfig(struct { - Type string - Settings map[string]string - }{alert.Type, alert.Settings}), - }) - } - - tableWidth := m.termWidth - 6 - if tableWidth < 40 { - tableWidth = 40 - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(alertBorderStyle). - Width(tableWidth). - Headers("#", "NAME", "TYPE", "CONFIG"). - Rows(rows...). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return alertHeaderStyle + return m.renderTable( + []string{"#", "NAME", "TYPE", "CONFIG"}, + len(m.alerts), + func(start, end int) [][]string { + var rows [][]string + for i := start; i < end; i++ { + a := m.alerts[i] + rows = append(rows, []string{ + fmt.Sprintf("%d", i+1), + m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)), + fmtAlertType(a.Type), + fmtAlertConfig(struct { + Type string + Settings map[string]string + }{a.Type, a.Settings}), + }) } - if row == selectedVisual { - return alertSelectedStyle - } - return alertCellStyle - }) - - return "\n" + t.Render() + return rows + }, + nil, nil, + ) } func (m *Model) initAlertHuhForm() tea.Cmd { diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 1644a35..e5ebbfe 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -12,33 +12,14 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" ) var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} -var ( - siteHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true). - Padding(0, 1) - - siteCellStyle = lipgloss.NewStyle().Padding(0, 1) - - siteSelectedStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#3b3b5c")) - - siteBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) - - siteGroupStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#7D56F4")) -) +var siteGroupStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")) type siteFormData struct { Name string @@ -219,111 +200,80 @@ func (m Model) viewSitesTab() string { return "\n No sites configured. Press [n] to add one." } - end := m.tableOffset + m.maxTableRows - if end > len(m.sites) { - end = len(m.sites) - } - - selectedVisual := m.cursor - m.tableOffset - - var rows [][]string - var groupRows []int - for i := m.tableOffset; i < end; i++ { - site := m.sites[i] - - if site.Type == "group" { - groupRows = append(groupRows, i-m.tableOffset) - arrow := "▾" - if m.collapsed[site.ID] { - arrow = "▸" - } - rows = append(rows, []string{ - strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)), - "group", - fmtStatus(site.Status, site.Paused), - subtleStyle.Render("—"), - subtleStyle.Render("—"), - subtleStyle.Render(strings.Repeat("·", sparkWidth)), - subtleStyle.Render("-"), - subtleStyle.Render("—"), - }) - continue - } - - name := site.Name - if site.ParentID > 0 { - prefix := "├" - if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { - prefix = "└" - } - name = prefix + " " + limitStr(name, 11) - } else { - name = limitStr(name, 13) - } - - hist, _ := monitor.GetHistory(site.ID) - var spark string - if site.Type == "push" { - spark = heartbeatSparkline(hist.Statuses, sparkWidth) - } else { - spark = latencySparkline(hist.Latencies, sparkWidth) - } - - rows = append(rows, []string{ - strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), name), - site.Type, - fmtStatus(site.Status, site.Paused), - fmtLatency(site.Latency), - fmtUptime(hist.TotalChecks, hist.UpChecks), - spark, - fmtSSL(site), - fmtRetries(site), - }) - } - - isGroupRow := func(row int) bool { - for _, g := range groupRows { - if g == row { - return true - } - } - return false - } - - tableWidth := m.termWidth - 6 - if tableWidth < 40 { - tableWidth = 40 - } - - // column widths: #=6, name=flex, type=10, status=10, latency=8, uptime=8, history=sparkWidth+4, ssl=7, retry=9 colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(siteBorderStyle). - Width(tableWidth). - Headers("#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). - Rows(rows...). - StyleFunc(func(row, col int) lipgloss.Style { - var base lipgloss.Style - if row == table.HeaderRow { - base = siteHeaderStyle - } else if row == selectedVisual { - base = siteSelectedStyle - } else if isGroupRow(row) { - base = siteGroupStyle - } else { - base = siteCellStyle - } - if col < len(colWidths) && colWidths[col] > 0 { - base = base.Width(colWidths[col]) - } - return base - }) + var groupRows map[int]bool + return m.renderTable( + []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"}, + len(m.sites), + func(start, end int) [][]string { + groupRows = make(map[int]bool) + var rows [][]string + for i := start; i < end; i++ { + site := m.sites[i] - return "\n" + t.Render() + if site.Type == "group" { + groupRows[i-start] = true + arrow := "▾" + if m.collapsed[site.ID] { + arrow = "▸" + } + rows = append(rows, []string{ + strconv.Itoa(i + 1), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)), + "group", + fmtStatus(site.Status, site.Paused), + subtleStyle.Render("—"), + subtleStyle.Render("—"), + subtleStyle.Render(strings.Repeat("·", sparkWidth)), + subtleStyle.Render("-"), + subtleStyle.Render("—"), + }) + continue + } + + name := site.Name + if site.ParentID > 0 { + prefix := "├" + if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { + prefix = "└" + } + name = prefix + " " + limitStr(name, 11) + } else { + name = limitStr(name, 13) + } + + hist, _ := monitor.GetHistory(site.ID) + var spark string + if site.Type == "push" { + spark = heartbeatSparkline(hist.Statuses, sparkWidth) + } else { + spark = latencySparkline(hist.Latencies, sparkWidth) + } + + rows = append(rows, []string{ + strconv.Itoa(i + 1), + m.zones.Mark(fmt.Sprintf("site-%d", i), name), + site.Type, + fmtStatus(site.Status, site.Paused), + fmtLatency(site.Latency), + fmtUptime(hist.TotalChecks, hist.UpChecks), + spark, + fmtSSL(site), + fmtRetries(site), + }) + } + return rows + }, + colWidths, + func(row, col int) *lipgloss.Style { + if groupRows[row] { + s := siteGroupStyle + return &s + } + return nil + }, + ) } func (m *Model) initSiteHuhForm() tea.Cmd { diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index d82e5fb..46c5679 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -6,26 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" -) - -var ( - userHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true). - Padding(0, 1) - - userCellStyle = lipgloss.NewStyle().Padding(0, 1) - - userSelectedStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#3b3b5c")) - - userBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) ) type userFormData struct { @@ -53,46 +33,24 @@ func (m Model) viewUsersTab() string { return "\n No users configured. Press [n] to add one." } - end := m.tableOffset + m.maxTableRows - if end > len(m.users) { - end = len(m.users) - } - - selectedVisual := m.cursor - m.tableOffset - - var rows [][]string - for i := m.tableOffset; i < end; i++ { - u := m.users[i] - rows = append(rows, []string{ - fmt.Sprintf("%d", i+1), - m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), - fmtRole(u.Role), - fmtKey(u.PublicKey), - }) - } - - tableWidth := m.termWidth - 6 - if tableWidth < 40 { - tableWidth = 40 - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(userBorderStyle). - Width(tableWidth). - Headers("#", "USERNAME", "ROLE", "PUBLIC KEY"). - Rows(rows...). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return userHeaderStyle + return m.renderTable( + []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}, + len(m.users), + func(start, end int) [][]string { + var rows [][]string + for i := start; i < end; i++ { + u := m.users[i] + rows = append(rows, []string{ + fmt.Sprintf("%d", i+1), + m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), + fmtRole(u.Role), + fmtKey(u.PublicKey), + }) } - if row == selectedVisual { - return userSelectedStyle - } - return userCellStyle - }) - - return "\n" + t.Render() + return rows + }, + nil, nil, + ) } func (m *Model) initUserHuhForm() tea.Cmd { diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go new file mode 100644 index 0000000..be8719e --- /dev/null +++ b/internal/tui/table_helpers.go @@ -0,0 +1,75 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +var ( + tableHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true). + Padding(0, 1) + + tableCellStyle = lipgloss.NewStyle().Padding(0, 1) + + tableSelectedStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#3b3b5c")) + + tableBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444")) +) + +type StyleOverride func(row, col int) *lipgloss.Style + +func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string { + if items == 0 { + return "" + } + + end := m.tableOffset + m.maxTableRows + if end > items { + end = items + } + + selectedVisual := m.cursor - m.tableOffset + rows := buildRows(m.tableOffset, end) + + tableWidth := m.termWidth - 6 + if tableWidth < 40 { + tableWidth = 40 + } + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tableBorderStyle). + Width(tableWidth). + Headers(headers...). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return tableHeaderStyle + } + if styleOverride != nil { + if s := styleOverride(row, col); s != nil { + if col < len(colWidths) && colWidths[col] > 0 { + return s.Width(colWidths[col]) + } + return *s + } + } + base := tableCellStyle + if row == selectedVisual { + base = tableSelectedStyle + } + if col < len(colWidths) && colWidths[col] > 0 { + base = base.Width(colWidths[col]) + } + return base + }) + + return "\n" + t.Render() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4324c65..5e0e7b6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -346,11 +346,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - maxTabs := 3 - if !m.isAdmin { - maxTabs = 2 + tabCount := 3 + if m.isAdmin { + tabCount = 4 } - for i := 0; i <= maxTabs; i++ { + for i := 0; i < tabCount; i++ { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { m.switchTab(i) return m, nil @@ -477,6 +477,19 @@ func (m *Model) refreshData() { } } m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) + + listLen := len(m.sites) + if m.currentTab == 1 { + listLen = len(m.alerts) + } else if m.currentTab == 3 { + listLen = len(m.users) + } + if listLen > 0 && m.cursor >= listLen { + m.cursor = listLen - 1 + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } } func (m *Model) submitForm() { From f023e38fdc1317f5b6337f2f50a05eaa19766488 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 08:21:17 -0400 Subject: [PATCH 12/35] refactor(monitor): encapsulate engine state, add graceful shutdown and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all monitor package-level mutable state with Engine struct. All state (liveState, logStore, histories, tokenIndex, HTTP clients) is now encapsulated in Engine, created via NewEngine(store). Key changes: - Engine struct holds all monitor state with proper mutex protection - Engine.Start(ctx) and monitorRoutine respect context cancellation for graceful shutdown — no more leaked goroutines - cluster.runFollowerLoop also respects context for clean exit - Token index (map[string]int) for O(1) push heartbeat lookup, replacing O(n) linear scan through LiveState - UpdateSiteConfig preserves 8 runtime fields instead of copying 17 config fields individually - triggerAlert goroutines get 30s timeout context - All consumers (TUI, server, cluster, main) receive *Engine via constructor/parameter — no package-level state access - main.go creates context.WithCancel, passes to engine and cluster First test suite: 12 tests across store and alert packages - Store: CRUD for sites/alerts/users, push token generation, import/export round-trip, check history persistence - Alert: Discord/Slack/Webhook payload format, HTTP 4xx error propagation, Ntfy headers, unknown provider returns nil --- cmd/goupkeep/main.go | 31 +- internal/alert/alert_test.go | 109 +++++++ internal/cluster/cluster.go | 33 ++- internal/monitor/history.go | 55 ++-- internal/monitor/monitor.go | 500 ++++++++++++++++++-------------- internal/server/server.go | 19 +- internal/store/sqlstore_test.go | 231 +++++++++++++++ internal/tui/tab_alerts.go | 5 +- internal/tui/tab_sites.go | 9 +- internal/tui/tab_users.go | 5 +- internal/tui/tui.go | 23 +- 11 files changed, 705 insertions(+), 315 deletions(-) create mode 100644 internal/alert/alert_test.go create mode 100644 internal/store/sqlstore_test.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 921a3c7..5962e37 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "go-upkeep/internal/cluster" @@ -68,9 +69,6 @@ func main() { if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { clusterKey = v } - if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { - monitor.SetInsecureSkipVerify(true) - } port := flag.Int("port", portVal, "SSH Port") flagDBType := flag.String("db-type", dbType, "Database type") @@ -115,26 +113,34 @@ func main() { fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) } - monitor.InitHistoryFromStore(s) - monitor.StartEngine(s) + eng := monitor.NewEngine(s) + if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { + eng.SetInsecureSkipVerify(true) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + eng.InitHistory() + eng.Start(ctx) server.Start(server.ServerConfig{ Port: httpPort, EnableStatus: enableStatus, Title: statusTitle, ClusterKey: clusterKey, - }, s) + }, s, eng) - cluster.Start(cluster.Config{ + cluster.Start(ctx, cluster.Config{ Mode: clusterMode, PeerURL: clusterPeer, SharedKey: clusterKey, - }) + }, eng) - startSSHServer(*port, s) + startSSHServer(*port, s, eng) if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - p := tea.NewProgram(tui.InitialModel(true, s), tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Printf("Error: %v\n", err) } @@ -145,9 +151,10 @@ func main() { <-done fmt.Println("Shutting down...") } + cancel() } -func startSSHServer(port int, db store.Store) { +func startSSHServer(port int, db store.Store, eng *monitor.Engine) { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithHostKeyPath(".ssh/id_ed25519"), @@ -156,7 +163,7 @@ func startSSHServer(port int, db store.Store) { }), wish.WithMiddleware( bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { - return tui.InitialModel(false, db), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} + return tui.InitialModel(false, db, eng), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} }), ), ) diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go new file mode 100644 index 0000000..348f2c9 --- /dev/null +++ b/internal/alert/alert_test.go @@ -0,0 +1,109 @@ +package alert + +import ( + "encoding/json" + "go-upkeep/internal/models" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPProviderDiscord(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) + if err := p.Send("Test Title", "Test Body"); err != nil { + t.Fatalf("Send: %v", err) + } + + if received["content"] != "**Test Title**\nTest Body" { + t.Errorf("unexpected payload: %s", received["content"]) + } +} + +func TestHTTPProviderSlack(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}}) + if err := p.Send("Alert", "Message"); err != nil { + t.Fatalf("Send: %v", err) + } + + if received["text"] != "*Alert*\nMessage" { + t.Errorf("unexpected payload: %s", received["text"]) + } +} + +func TestHTTPProviderWebhook(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}}) + if err := p.Send("Title", "Body"); err != nil { + t.Fatalf("Send: %v", err) + } + + if received["title"] != "Title" || received["message"] != "Body" || received["status"] != "alert" { + t.Errorf("unexpected webhook payload: %v", received) + } +} + +func TestHTTPProviderErrorOnHTTP4xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) + if err := p.Send("Test", "Test"); err == nil { + t.Fatal("expected error on 403 response") + } +} + +func TestNtfyProvider(t *testing.T) { + var title, body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + title = r.Header.Get("Title") + buf := make([]byte, 1024) + n, _ := r.Body.Read(buf) + body = string(buf[:n]) + w.WriteHeader(200) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "ntfy", Settings: map[string]string{ + "url": srv.URL, + "topic": "test", + }}) + if err := p.Send("Alert Title", "Alert Body"); err != nil { + t.Fatalf("Send: %v", err) + } + + if title != "Alert Title" { + t.Errorf("expected title 'Alert Title', got '%s'", title) + } + if body != "Alert Body" { + t.Errorf("expected body 'Alert Body', got '%s'", body) + } +} + +func TestGetProviderUnknown(t *testing.T) { + p := GetProvider(models.AlertConfig{Type: "unknown"}) + if p != nil { + t.Error("expected nil for unknown provider type") + } +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 295443d..f986c29 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -1,6 +1,7 @@ package cluster import ( + "context" "fmt" "go-upkeep/internal/monitor" "net/http" @@ -14,13 +15,13 @@ type Config struct { SharedKey string // Security Key } -func Start(cfg Config) { +func Start(ctx context.Context, cfg Config, eng *monitor.Engine) { if cfg.Mode == "leader" { fmt.Println("Cluster: Running as LEADER (Active)") if cfg.SharedKey != "" { fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.") } - monitor.SetEngineActive(true) + eng.SetActive(true) return } @@ -29,20 +30,22 @@ func Start(cfg Config) { if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") { fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.") } - monitor.SetEngineActive(false) - go runFollowerLoop(cfg) + eng.SetActive(false) + go runFollowerLoop(ctx, cfg, eng) } } -func runFollowerLoop(cfg Config) { +func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) { client := http.Client{Timeout: 2 * time.Second} - - // Failover Configuration failures := 0 threshold := 3 for { - time.Sleep(5 * time.Second) + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil) if cfg.SharedKey != "" { @@ -59,17 +62,15 @@ func runFollowerLoop(cfg Config) { if isLeaderHealthy { failures = 0 - if monitor.IsEngineActive() { - // Leader is back, yield - monitor.SetEngineActive(false) - monitor.AddLog("Cluster: Leader detected. Switching to PASSIVE.") + if eng.IsActive() { + eng.SetActive(false) + eng.AddLog("Cluster: Leader detected. Switching to PASSIVE.") } } else { failures++ - // If failures exceed threshold, take over - if failures >= threshold && !monitor.IsEngineActive() { - monitor.SetEngineActive(true) - monitor.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.") + if failures >= threshold && !eng.IsActive() { + eng.SetActive(true) + eng.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.") } } } diff --git a/internal/monitor/history.go b/internal/monitor/history.go index dd3f375..1049e04 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -1,10 +1,6 @@ package monitor -import ( - "go-upkeep/internal/store" - "sync" - "time" -) +import "time" const maxHistoryLen = 30 @@ -15,19 +11,14 @@ type SiteHistory struct { UpChecks int } -var ( - histories = make(map[int]*SiteHistory) - historyMu sync.RWMutex -) - -func InitHistoryFromStore(s store.Store) { - all, err := s.LoadAllHistory(maxHistoryLen) +func (e *Engine) InitHistory() { + all, err := e.db.LoadAllHistory(maxHistoryLen) if err != nil { - AddLog("Failed to load check history: " + err.Error()) + e.AddLog("Failed to load check history: " + err.Error()) return } - historyMu.Lock() - defer historyMu.Unlock() + e.histMu.Lock() + defer e.histMu.Unlock() for siteID, records := range all { h := &SiteHistory{} for _, r := range records { @@ -38,21 +29,21 @@ func InitHistoryFromStore(s store.Store) { h.Latencies = append(h.Latencies, time.Duration(r.LatencyNs)) h.Statuses = append(h.Statuses, r.IsUp) } - histories[siteID] = h + e.histories[siteID] = h } if len(all) > 0 { - AddLog("Loaded check history from database") + e.AddLog("Loaded check history from database") } } -func RecordCheck(siteID int, latency time.Duration, isUp bool) { - historyMu.Lock() - defer historyMu.Unlock() +func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) { + e.histMu.Lock() + defer e.histMu.Unlock() - h, ok := histories[siteID] + h, ok := e.histories[siteID] if !ok { h = &SiteHistory{} - histories[siteID] = h + e.histories[siteID] = h } h.TotalChecks++ @@ -70,15 +61,13 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) { h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] } - if db != nil { - go func() { _ = db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() - } + go func() { _ = e.db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() } -func GetHistory(siteID int) (SiteHistory, bool) { - historyMu.RLock() - defer historyMu.RUnlock() - h, ok := histories[siteID] +func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) { + e.histMu.RLock() + defer e.histMu.RUnlock() + h, ok := e.histories[siteID] if !ok { return SiteHistory{}, false } @@ -93,8 +82,8 @@ func GetHistory(siteID int) (SiteHistory, bool) { return cp, true } -func RemoveHistory(siteID int) { - historyMu.Lock() - defer historyMu.Unlock() - delete(histories, siteID) +func (e *Engine) removeHistory(siteID int) { + e.histMu.Lock() + defer e.histMu.Unlock() + delete(e.histories, siteID) } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index db11caf..2d2af10 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -18,207 +18,271 @@ import ( probing "github.com/prometheus-community/pro-bing" ) -// --- LOGGING --- -var ( - LogStore []string - LogMutex sync.RWMutex -) +type Engine struct { + mu sync.RWMutex + liveState map[int]models.Site -func AddLog(msg string) { - LogMutex.Lock() - defer LogMutex.Unlock() - ts := time.Now().Format("15:04:05") - entry := fmt.Sprintf("[%s] %s", ts, msg) - LogStore = append([]string{entry}, LogStore...) - if len(LogStore) > 100 { - LogStore = LogStore[:100] + logMu sync.RWMutex + logStore []string + + activeMu sync.RWMutex + isActive bool + + histMu sync.RWMutex + histories map[int]*SiteHistory + + tokenIndex map[string]int + + db store.Store + insecureSkipVerify bool + strictClient *http.Client + insecureClient *http.Client +} + +func NewEngine(s store.Store) *Engine { + return &Engine{ + liveState: make(map[int]models.Site), + histories: make(map[int]*SiteHistory), + tokenIndex: make(map[string]int), + isActive: true, + db: s, + strictClient: &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, + }, + insecureClient: &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + }, } } -func GetLogs() []string { - LogMutex.RLock() - defer LogMutex.RUnlock() - logs := make([]string, len(LogStore)) - copy(logs, LogStore) +func (e *Engine) SetInsecureSkipVerify(skip bool) { + e.insecureSkipVerify = skip +} + +func (e *Engine) AddLog(msg string) { + e.logMu.Lock() + defer e.logMu.Unlock() + ts := time.Now().Format("15:04:05") + entry := fmt.Sprintf("[%s] %s", ts, msg) + e.logStore = append([]string{entry}, e.logStore...) + if len(e.logStore) > 100 { + e.logStore = e.logStore[:100] + } +} + +func (e *Engine) GetLogs() []string { + e.logMu.RLock() + defer e.logMu.RUnlock() + logs := make([]string, len(e.logStore)) + copy(logs, e.logStore) return logs } -// --- ENGINE --- - -var ( - LiveState = make(map[int]models.Site) - Mutex sync.RWMutex - - // Global Switch for HA - isActive = true - activeMutex sync.RWMutex - - insecureSkipVerify bool - - db store.Store - - strictClient = &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, - } - insecureClient = &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, - } -) - -func SetInsecureSkipVerify(skip bool) { - insecureSkipVerify = skip -} - -func SetEngineActive(active bool) { - activeMutex.Lock() - defer activeMutex.Unlock() - if isActive != active { - isActive = active +func (e *Engine) SetActive(active bool) { + e.activeMu.Lock() + defer e.activeMu.Unlock() + if e.isActive != active { + e.isActive = active status := "RESUMED (Active)" if !active { status = "PAUSED (Passive)" } - AddLog(fmt.Sprintf("Engine %s", status)) + e.AddLog(fmt.Sprintf("Engine %s", status)) } } -func IsEngineActive() bool { - activeMutex.RLock() - defer activeMutex.RUnlock() - return isActive +func (e *Engine) IsActive() bool { + e.activeMu.RLock() + defer e.activeMu.RUnlock() + return e.isActive } -func RecordHeartbeat(token string) bool { - if !IsEngineActive() { - return false - } // Only Leader accepts Push - - Mutex.Lock() - defer Mutex.Unlock() - var targetID int = -1 - for id, s := range LiveState { - if s.Type == "push" && s.Token == token { - targetID = id - break - } +func (e *Engine) GetAllSites() []models.Site { + e.mu.RLock() + defer e.mu.RUnlock() + sites := make([]models.Site, 0, len(e.liveState)) + for _, s := range e.liveState { + sites = append(sites, s) } - if targetID == -1 { + return sites +} + +func (e *Engine) GetLiveState() map[int]models.Site { + e.mu.RLock() + defer e.mu.RUnlock() + cp := make(map[int]models.Site, len(e.liveState)) + for k, v := range e.liveState { + cp[k] = v + } + return cp +} + +func (e *Engine) RecordHeartbeat(token string) bool { + if !e.IsActive() { + return false + } + + e.mu.Lock() + defer e.mu.Unlock() + + targetID, ok := e.tokenIndex[token] + if !ok { + return false + } + + site, exists := e.liveState[targetID] + if !exists { return false } - site := LiveState[targetID] site.LastCheck = time.Now() wasDown := site.Status == "DOWN" site.Status = "UP" site.FailureCount = 0 site.Latency = 0 - LiveState[targetID] = site + e.liveState[targetID] = site if wasDown { - AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) - triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) + e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) + e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) } return true } -func StartEngine(s store.Store) { - db = s +func (e *Engine) addToTokenIndex(site models.Site) { + if site.Type == "push" && site.Token != "" { + e.tokenIndex[site.Token] = site.ID + } +} + +func (e *Engine) removeFromTokenIndex(id int) { + for token, sid := range e.tokenIndex { + if sid == id { + delete(e.tokenIndex, token) + return + } + } +} + +func (e *Engine) Start(ctx context.Context) { go func() { for { - sites, err := db.GetSites() + select { + case <-ctx.Done(): + return + default: + } + + sites, err := e.db.GetSites() if err != nil { - AddLog(fmt.Sprintf("Failed to load sites: %v", err)) - time.Sleep(5 * time.Second) + e.AddLog(fmt.Sprintf("Failed to load sites: %v", err)) + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } continue } for _, s := range sites { - Mutex.RLock() - _, exists := LiveState[s.ID] - Mutex.RUnlock() + e.mu.RLock() + _, exists := e.liveState[s.ID] + e.mu.RUnlock() if !exists { - Mutex.Lock() + e.mu.Lock() s.Status = "PENDING" if s.Type == "push" { s.LastCheck = time.Now() } - LiveState[s.ID] = s - Mutex.Unlock() - go monitorRoutine(s.ID) + e.liveState[s.ID] = s + e.addToTokenIndex(s) + e.mu.Unlock() + go e.monitorRoutine(ctx, s.ID) } } - time.Sleep(5 * time.Second) + + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } } }() } -func UpdateSiteConfig(site models.Site) { - Mutex.Lock() - defer Mutex.Unlock() - if s, ok := LiveState[site.ID]; ok { - s.Name = site.Name - s.URL = site.URL - s.Type = site.Type - s.Interval = site.Interval - s.AlertID = site.AlertID - s.CheckSSL = site.CheckSSL - s.ExpiryThreshold = site.ExpiryThreshold - s.MaxRetries = site.MaxRetries - s.Hostname = site.Hostname - s.Port = site.Port - s.Timeout = site.Timeout - s.Method = site.Method - s.Description = site.Description - s.ParentID = site.ParentID - s.AcceptedCodes = site.AcceptedCodes - s.DNSResolveType = site.DNSResolveType - s.DNSServer = site.DNSServer - s.IgnoreTLS = site.IgnoreTLS - s.Paused = site.Paused - LiveState[site.ID] = s +func (e *Engine) UpdateSiteConfig(site models.Site) { + e.mu.Lock() + defer e.mu.Unlock() + 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 + e.liveState[site.ID] = site + e.addToTokenIndex(site) } } -func RemoveSite(id int) { - Mutex.Lock() - delete(LiveState, id) - Mutex.Unlock() - RemoveHistory(id) +func (e *Engine) RemoveSite(id int) { + e.mu.Lock() + e.removeFromTokenIndex(id) + delete(e.liveState, id) + e.mu.Unlock() + e.removeHistory(id) } -func ToggleSitePause(id int) bool { - Mutex.Lock() - defer Mutex.Unlock() - site, ok := LiveState[id] +func (e *Engine) ToggleSitePause(id int) bool { + e.mu.Lock() + defer e.mu.Unlock() + site, ok := e.liveState[id] if !ok { return false } site.Paused = !site.Paused - LiveState[id] = site + e.liveState[id] = site if site.Paused { - AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name)) + e.AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name)) } else { - AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name)) + e.AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name)) } return site.Paused } -func monitorRoutine(id int) { - checkByID(id) +func (e *Engine) monitorRoutine(ctx context.Context, id int) { + e.checkByID(id) for { - if !IsEngineActive() { - time.Sleep(5 * time.Second) + select { + case <-ctx.Done(): + return + default: + } + + if !e.IsActive() { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } continue } - Mutex.RLock() - site, exists := LiveState[id] - Mutex.RUnlock() + e.mu.RLock() + site, exists := e.liveState[id] + e.mu.RUnlock() if !exists { return } if site.Paused { - time.Sleep(5 * time.Second) + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } continue } @@ -226,72 +290,52 @@ func monitorRoutine(id int) { if interval < 5 { interval = 5 } - time.Sleep(time.Duration(interval) * time.Second) - checkByID(id) + select { + case <-time.After(time.Duration(interval) * time.Second): + case <-ctx.Done(): + return + } + e.checkByID(id) } } -func checkByID(id int) { - if !IsEngineActive() { +func (e *Engine) checkByID(id int) { + if !e.IsActive() { return } - Mutex.RLock() - site, exists := LiveState[id] - Mutex.RUnlock() + e.mu.RLock() + site, exists := e.liveState[id] + e.mu.RUnlock() if !exists || site.Paused { return } switch site.Type { case "http": - checkHTTP(site) + e.checkHTTP(site) case "push": - checkPush(site) + e.checkPush(site) case "ping": - checkPing(site) + e.checkPing(site) case "port": - checkPort(site) + e.checkPort(site) case "dns": - checkDNS(site) + e.checkDNS(site) case "group": - checkGroup(site) + e.checkGroup(site) } } -func checkPush(site models.Site) { +func (e *Engine) checkPush(site models.Site) { deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second) if time.Now().After(deadline) { - handleStatusChange(site, "DOWN", 0, 0) - } else { - if site.Status != "UP" { - handleStatusChange(site, "UP", 200, 0) - } + e.handleStatusChange(site, "DOWN", 0, 0) + } else if site.Status != "UP" { + e.handleStatusChange(site, "UP", 200, 0) } } -func isCodeAccepted(code int, accepted string) bool { - if accepted == "" { - return code >= 200 && code < 300 - } - for _, part := range strings.Split(accepted, ",") { - part = strings.TrimSpace(part) - if strings.Contains(part, "-") { - bounds := strings.SplitN(part, "-", 2) - lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) - hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) - if err1 == nil && err2 == nil && code >= lo && code <= hi { - return true - } - } else { - if v, err := strconv.Atoi(part); err == nil && code == v { - return true - } - } - } - return false -} - -func checkHTTP(site models.Site) { +func (e *Engine) checkHTTP(site models.Site) { method := site.Method if method == "" { method = "GET" @@ -303,13 +347,13 @@ func checkHTTP(site models.Site) { req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) if err != nil { - handleStatusChange(site, "DOWN", 0, 0) + e.handleStatusChange(site, "DOWN", 0, 0) return } - client := strictClient - if insecureSkipVerify || site.IgnoreTLS { - client = insecureClient + client := e.strictClient + if e.insecureSkipVerify || site.IgnoreTLS { + client = e.insecureClient } start := time.Now() @@ -343,12 +387,11 @@ func checkHTTP(site models.Site) { updatedSite.CertExpiry = certExpiry updatedSite.Latency = latency updatedSite.LastCheck = time.Now() - handleStatusChange(updatedSite, rawStatus, rawCode, latency) + e.handleStatusChange(updatedSite, rawStatus, rawCode, latency) } -func handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) { - // Double check we are still leader before alerting - if !IsEngineActive() { +func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) { + if !e.IsActive() { return } @@ -360,9 +403,9 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti if newState.FailureCount > site.MaxRetries { newState.Status = rawStatus newState.FailureCount = site.MaxRetries + 1 - AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name)) + e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name)) } else { - AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries)) + e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries)) } } else if rawStatus == "UP" { newState.FailureCount = 0 @@ -375,20 +418,20 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti if site.Type == "http" && site.CheckSSL && site.HasSSL { daysLeft := int(time.Until(site.CertExpiry).Hours() / 24) if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" { - triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft)) + e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft)) newState.SentSSLWarning = true } else if daysLeft > site.ExpiryThreshold { newState.SentSSLWarning = false } } - Mutex.Lock() - if _, ok := LiveState[site.ID]; ok { - LiveState[site.ID] = newState + e.mu.Lock() + if _, ok := e.liveState[site.ID]; ok { + e.liveState[site.ID] = newState } - Mutex.Unlock() + e.mu.Unlock() - RecordCheck(site.ID, latency, rawStatus == "UP") + e.recordCheck(site.ID, latency, rawStatus == "UP") isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" { @@ -396,24 +439,26 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti if site.Type == "push" { msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name) } - triggerAlert(site.AlertID, "🚨 ALERT", msg) + e.triggerAlert(site.AlertID, "🚨 ALERT", msg) } if isBroken(site.Status) && newState.Status == "UP" { - triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) + e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) } } -func triggerAlert(alertID int, title, message string) { - if db == nil { - return - } - cfg, err := db.GetAlert(alertID) +func (e *Engine) triggerAlert(alertID int, title, message string) { + cfg, err := e.db.GetAlert(alertID) if err != nil { return } provider := alert.GetProvider(cfg) if provider != nil { - go func() { provider.Send(title, message) }() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = ctx + _ = provider.Send(title, message) + }() } } @@ -424,7 +469,29 @@ func siteTimeout(site models.Site) time.Duration { return 5 * time.Second } -func checkPing(site models.Site) { +func isCodeAccepted(code int, accepted string) bool { + if accepted == "" { + return code >= 200 && code < 300 + } + for _, part := range strings.Split(accepted, ",") { + part = strings.TrimSpace(part) + if strings.Contains(part, "-") { + bounds := strings.SplitN(part, "-", 2) + lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) + hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) + if err1 == nil && err2 == nil && code >= lo && code <= hi { + return true + } + } else { + if v, err := strconv.Atoi(part); err == nil && code == v { + return true + } + } + } + return false +} + +func (e *Engine) checkPing(site models.Site) { host := site.Hostname if host == "" { host = site.URL @@ -432,8 +499,8 @@ func checkPing(site models.Site) { pinger, err := probing.NewPinger(host) if err != nil { - handleStatusChange(site, "DOWN", 0, 0) - AddLog(fmt.Sprintf("Ping '%s' resolve failed: %v", site.Name, err)) + e.handleStatusChange(site, "DOWN", 0, 0) + e.AddLog(fmt.Sprintf("Ping '%s' resolve failed: %v", site.Name, err)) return } pinger.Count = 1 @@ -448,7 +515,7 @@ func checkPing(site models.Site) { updatedSite := site updatedSite.Latency = latency updatedSite.LastCheck = time.Now() - handleStatusChange(updatedSite, "DOWN", 0, latency) + e.handleStatusChange(updatedSite, "DOWN", 0, latency) return } @@ -456,10 +523,10 @@ func checkPing(site models.Site) { updatedSite := site updatedSite.Latency = stats.AvgRtt updatedSite.LastCheck = time.Now() - handleStatusChange(updatedSite, "UP", 0, stats.AvgRtt) + e.handleStatusChange(updatedSite, "UP", 0, stats.AvgRtt) } -func checkPort(site models.Site) { +func (e *Engine) checkPort(site models.Site) { host := site.Hostname if host == "" { host = site.URL @@ -476,19 +543,19 @@ func checkPort(site models.Site) { updatedSite.LastCheck = time.Now() if err != nil { - handleStatusChange(updatedSite, "DOWN", 0, latency) + e.handleStatusChange(updatedSite, "DOWN", 0, latency) return } conn.Close() - handleStatusChange(updatedSite, "UP", 0, latency) + e.handleStatusChange(updatedSite, "UP", 0, latency) } -func checkGroup(site models.Site) { - Mutex.RLock() +func (e *Engine) checkGroup(site models.Site) { + e.mu.RLock() status := "UP" hasChildren := false allPaused := true - for _, child := range LiveState { + for _, child := range e.liveState { if child.ParentID != site.ID || child.Type == "group" { continue } @@ -505,23 +572,23 @@ func checkGroup(site models.Site) { status = "PENDING" } } - Mutex.RUnlock() + e.mu.RUnlock() if !hasChildren { status = "PENDING" } - Mutex.Lock() - s := LiveState[site.ID] + e.mu.Lock() + s := e.liveState[site.ID] s.Status = status if hasChildren && allPaused { s.Paused = true } - LiveState[site.ID] = s - Mutex.Unlock() + e.liveState[site.ID] = s + e.mu.Unlock() } -func checkDNS(site models.Site) { +func (e *Engine) checkDNS(site models.Site) { host := site.Hostname if host == "" { host = site.URL @@ -562,8 +629,7 @@ func checkDNS(site models.Site) { c.Timeout = siteTimeout(site) start := time.Now() - r, rtt, err := c.Exchange(m, server) - _ = rtt + r, _, err := c.Exchange(m, server) latency := time.Since(start) updatedSite := site @@ -571,14 +637,14 @@ func checkDNS(site models.Site) { updatedSite.LastCheck = time.Now() if err != nil { - handleStatusChange(updatedSite, "DOWN", 0, latency) + e.handleStatusChange(updatedSite, "DOWN", 0, latency) return } if r.Rcode != dns.RcodeSuccess { - handleStatusChange(updatedSite, "DOWN", r.Rcode, latency) + e.handleStatusChange(updatedSite, "DOWN", r.Rcode, latency) return } - handleStatusChange(updatedSite, "UP", 0, latency) + e.handleStatusChange(updatedSite, "UP", 0, latency) } diff --git a/internal/server/server.go b/internal/server/server.go index b6a2b52..ac26bd2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -148,7 +148,7 @@ type ServerConfig struct { ClusterKey string // Shared Secret for Security } -func Start(cfg ServerConfig, s store.Store) { +func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { if cfg.ClusterKey == "" { fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") } @@ -161,7 +161,7 @@ func Start(cfg ServerConfig, s store.Store) { http.Error(w, "Missing token", 400) return } - if monitor.RecordHeartbeat(token) { + if eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { @@ -244,12 +244,10 @@ func Start(cfg ServerConfig, s store.Store) { // 6. Status Page if cfg.EnableStatus { - mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title) }) + mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { - monitor.Mutex.RLock() - defer monitor.Mutex.RUnlock() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(monitor.LiveState) + json.NewEncoder(w).Encode(eng.GetLiveState()) }) } @@ -262,13 +260,8 @@ func Start(cfg ServerConfig, s store.Store) { }() } -func renderStatusPage(w http.ResponseWriter, title string) { - monitor.Mutex.RLock() - var sites []models.Site - for _, s := range monitor.LiveState { - sites = append(sites, s) - } - monitor.Mutex.RUnlock() +func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { + sites := eng.GetAllSites() sort.Slice(sites, func(i, j int) bool { if sites[i].Status != sites[j].Status { diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go new file mode 100644 index 0000000..7ae2bd9 --- /dev/null +++ b/internal/store/sqlstore_test.go @@ -0,0 +1,231 @@ +package store + +import ( + "go-upkeep/internal/models" + "testing" +) + +func newTestStore(t *testing.T) *SQLStore { + t.Helper() + s, err := NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("NewSQLiteStore: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + return s +} + +func TestSiteCRUD(t *testing.T) { + s := newTestStore(t) + + sites, err := s.GetSites() + if err != nil { + t.Fatalf("GetSites: %v", err) + } + if len(sites) != 0 { + t.Fatalf("expected 0 sites, got %d", len(sites)) + } + + if err := s.AddSite(models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { + t.Fatalf("AddSite: %v", err) + } + + sites, err = s.GetSites() + if err != nil { + t.Fatalf("GetSites: %v", err) + } + if len(sites) != 1 { + t.Fatalf("expected 1 site, got %d", len(sites)) + } + if sites[0].Name != "Test" { + t.Errorf("expected name 'Test', got '%s'", sites[0].Name) + } + + sites[0].Name = "Updated" + if err := s.UpdateSite(sites[0]); err != nil { + t.Fatalf("UpdateSite: %v", err) + } + + sites, _ = s.GetSites() + if sites[0].Name != "Updated" { + t.Errorf("expected name 'Updated', got '%s'", sites[0].Name) + } + + if err := s.DeleteSite(sites[0].ID); err != nil { + t.Fatalf("DeleteSite: %v", err) + } + + sites, _ = s.GetSites() + if len(sites) != 0 { + t.Fatalf("expected 0 sites after delete, got %d", len(sites)) + } +} + +func TestAlertCRUD(t *testing.T) { + s := newTestStore(t) + + if err := s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil { + t.Fatalf("AddAlert: %v", err) + } + + alerts, err := s.GetAllAlerts() + if err != nil { + t.Fatalf("GetAllAlerts: %v", err) + } + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Type != "discord" { + t.Errorf("expected type 'discord', got '%s'", alerts[0].Type) + } + if alerts[0].Settings["url"] != "https://example.com/hook" { + t.Errorf("settings url mismatch") + } + + a, err := s.GetAlert(alerts[0].ID) + if err != nil { + t.Fatalf("GetAlert: %v", err) + } + if a.Name != "Discord" { + t.Errorf("expected name 'Discord', got '%s'", a.Name) + } + + if err := s.UpdateAlert(a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil { + t.Fatalf("UpdateAlert: %v", err) + } + + a, _ = s.GetAlert(a.ID) + if a.Type != "slack" { + t.Errorf("expected type 'slack', got '%s'", a.Type) + } + + if err := s.DeleteAlert(a.ID); err != nil { + t.Fatalf("DeleteAlert: %v", err) + } + + alerts, _ = s.GetAllAlerts() + if len(alerts) != 0 { + t.Fatalf("expected 0 alerts after delete, got %d", len(alerts)) + } +} + +func TestUserCRUD(t *testing.T) { + s := newTestStore(t) + + if err := s.AddUser("admin", "ssh-ed25519 AAAA...", "admin"); err != nil { + t.Fatalf("AddUser: %v", err) + } + + users, err := s.GetAllUsers() + if err != nil { + t.Fatalf("GetAllUsers: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } + if users[0].Username != "admin" { + t.Errorf("expected username 'admin', got '%s'", users[0].Username) + } + + if err := s.UpdateUser(users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + + users, _ = s.GetAllUsers() + if users[0].Username != "root" { + t.Errorf("expected username 'root', got '%s'", users[0].Username) + } + + if err := s.DeleteUser(users[0].ID); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + + users, _ = s.GetAllUsers() + if len(users) != 0 { + t.Fatalf("expected 0 users after delete, got %d", len(users)) + } +} + +func TestPushTokenGeneration(t *testing.T) { + s := newTestStore(t) + + if err := s.AddSite(models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil { + t.Fatalf("AddSite: %v", err) + } + + sites, _ := s.GetSites() + if len(sites) != 1 { + t.Fatalf("expected 1 site, got %d", len(sites)) + } + if sites[0].Token == "" { + t.Error("expected non-empty token for push monitor") + } + if len(sites[0].Token) != 32 { + t.Errorf("expected 32-char hex token, got %d chars", len(sites[0].Token)) + } +} + +func TestImportExport(t *testing.T) { + s := newTestStore(t) + + s.AddAlert("Test Alert", "webhook", map[string]string{"url": "https://example.com"}) + s.AddSite(models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}) + s.AddUser("user1", "ssh-ed25519 KEY", "user") + + backup, err := s.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if len(backup.Sites) != 1 || len(backup.Alerts) != 1 || len(backup.Users) != 1 { + t.Fatalf("export mismatch: %d sites, %d alerts, %d users", len(backup.Sites), len(backup.Alerts), len(backup.Users)) + } + + s2 := newTestStore(t) + if err := s2.ImportData(backup); err != nil { + t.Fatalf("ImportData: %v", err) + } + + sites, _ := s2.GetSites() + alerts, _ := s2.GetAllAlerts() + users, _ := s2.GetAllUsers() + if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 { + t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users)) + } +} + +func TestCheckHistory(t *testing.T) { + s := newTestStore(t) + + if err := s.SaveCheck(1, 5000000, true); err != nil { + t.Fatalf("SaveCheck: %v", err) + } + if err := s.SaveCheck(1, 10000000, false); err != nil { + t.Fatalf("SaveCheck: %v", err) + } + if err := s.SaveCheck(2, 3000000, true); err != nil { + t.Fatalf("SaveCheck site 2: %v", err) + } + + history, err := s.LoadAllHistory(10) + if err != nil { + t.Fatalf("LoadAllHistory: %v", err) + } + if len(history[1]) != 2 { + t.Fatalf("expected 2 records for site 1, got %d", len(history[1])) + } + if len(history[2]) != 1 { + t.Fatalf("expected 1 record for site 2, got %d", len(history[2])) + } + + upCount := 0 + for _, r := range history[1] { + if r.IsUp { + upCount++ + } + } + if upCount != 1 { + t.Errorf("expected 1 up record for site 1, got %d", upCount) + } +} diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 60b1890..11c9bf6 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "go-upkeep/internal/monitor" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -237,11 +236,11 @@ func (m *Model) submitAlertForm() { if m.editID > 0 { if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { - monitor.AddLog("Update alert failed: " + err.Error()) + m.engine.AddLog("Update alert failed: " + err.Error()) } } else { if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil { - monitor.AddLog("Add alert failed: " + err.Error()) + m.engine.AddLog("Add alert failed: " + err.Error()) } } m.state = stateDashboard diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index e5ebbfe..4b6ad00 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "go-upkeep/internal/models" - "go-upkeep/internal/monitor" "net/url" "strconv" "strings" @@ -243,7 +242,7 @@ func (m Model) viewSitesTab() string { name = limitStr(name, 13) } - hist, _ := monitor.GetHistory(site.ID) + hist, _ := m.engine.GetHistory(site.ID) var spark string if site.Type == "push" { spark = heartbeatSparkline(hist.Statuses, sparkWidth) @@ -508,12 +507,12 @@ func (m *Model) submitSiteForm() { if m.editID > 0 { if err := m.store.UpdateSite(site); err != nil { - monitor.AddLog("Update site failed: " + err.Error()) + m.engine.AddLog("Update site failed: " + err.Error()) } - monitor.UpdateSiteConfig(site) + m.engine.UpdateSiteConfig(site) } else { if err := m.store.AddSite(site); err != nil { - monitor.AddLog("Add site failed: " + err.Error()) + m.engine.AddLog("Add site failed: " + err.Error()) } } m.state = stateDashboard diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 46c5679..019bb03 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "go-upkeep/internal/monitor" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -104,11 +103,11 @@ func (m *Model) submitUserForm() { d := m.userFormData if m.editID > 0 { if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { - monitor.AddLog("Update user failed: " + err.Error()) + m.engine.AddLog("Update user failed: " + err.Error()) } } else { if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil { - monitor.AddLog("Add user failed: " + err.Error()) + m.engine.AddLog("Add user failed: " + err.Error()) } } m.state = stateUsers diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 5e0e7b6..89846a5 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -69,6 +69,7 @@ type Model struct { collapsed map[int]bool store store.Store + engine *monitor.Engine // harmonica animation state pulseSpring harmonica.Spring @@ -81,7 +82,7 @@ type Model struct { users []models.User } -func InitialModel(isAdmin bool, s store.Store) Model { +func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { vpLogs := viewport.New(100, 20) vpLogs.SetContent("Waiting for logs...") z := zone.New() @@ -92,6 +93,7 @@ func InitialModel(isAdmin bool, s store.Store) Model { maxTableRows: 5, isAdmin: isAdmin, store: s, + engine: eng, zones: z, pulseSpring: spring, collapsed: make(map[int]bool), @@ -112,18 +114,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.deleteTab { case 0: if err := m.store.DeleteSite(m.deleteID); err != nil { - monitor.AddLog("Delete site failed: " + err.Error()) + m.engine.AddLog("Delete site failed: " + err.Error()) } - monitor.RemoveSite(m.deleteID) + m.engine.RemoveSite(m.deleteID) m.adjustCursor(len(m.sites) - 1) case 1: if err := m.store.DeleteAlert(m.deleteID); err != nil { - monitor.AddLog("Delete alert failed: " + err.Error()) + m.engine.AddLog("Delete alert failed: " + err.Error()) } m.adjustCursor(len(m.alerts) - 1) case 3: if err := m.store.DeleteUser(m.deleteID); err != nil { - monitor.AddLog("Delete user failed: " + err.Error()) + m.engine.AddLog("Delete user failed: " + err.Error()) } m.adjustCursor(len(m.users) - 1) } @@ -317,7 +319,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "p": if m.currentTab == 0 && len(m.sites) > 0 { site := m.sites[m.cursor] - monitor.ToggleSitePause(site.ID) + m.engine.ToggleSitePause(site.ID) site.Paused = !site.Paused _ = m.store.UpdateSitePaused(site.ID, site.Paused) m.refreshData() @@ -433,12 +435,7 @@ func (m *Model) adjustCursor(newLen int) { } func (m *Model) refreshData() { - monitor.Mutex.RLock() - var allSites []models.Site - for _, s := range monitor.LiveState { - allSites = append(allSites, s) - } - monitor.Mutex.RUnlock() + allSites := m.engine.GetAllSites() var groups, ungrouped []models.Site children := make(map[int][]models.Site) @@ -476,7 +473,7 @@ func (m *Model) refreshData() { m.users = users } } - m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) + m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) listLen := len(m.sites) if m.currentTab == 1 { From 52a54f9c5cc33c8832b3090c23d3d085cd4623a6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 10:53:38 -0400 Subject: [PATCH 13/35] feat(alert): add Telegram, PagerDuty, Pushover, Gotify providers Expand alert provider count from 5 to 9. All new providers use the shared HTTPProvider with closure-based payload functions. Includes TUI form support and tests for each provider. --- internal/alert/alert.go | 76 ++++++++++++++++++++ internal/alert/alert_test.go | 104 +++++++++++++++++++++++++++ internal/tui/tab_alerts.go | 131 ++++++++++++++++++++++++++++++++++- 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index d67a30d..c60013f 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -7,6 +7,7 @@ import ( "go-upkeep/internal/models" "net/http" "net/smtp" + "strconv" "strings" "time" ) @@ -52,6 +53,52 @@ func webhookPayload(title, message string) ([]byte, error) { return json.Marshal(map[string]string{"title": title, "message": message, "status": "alert"}) } +func telegramPayload(chatID string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{ + "chat_id": chatID, + "text": fmt.Sprintf("*%s*\n%s", title, message), + "parse_mode": "Markdown", + }) + } +} + +func pagerdutyPayload(routingKey, severity string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]any{ + "routing_key": routingKey, + "event_action": "trigger", + "payload": map[string]string{ + "summary": fmt.Sprintf("%s: %s", title, message), + "source": "go-upkeep", + "severity": severity, + }, + }) + } +} + +func pushoverPayload(token, user string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{ + "token": token, + "user": user, + "title": title, + "message": message, + }) + } +} + +func gotifyPayload(priority string) PayloadFunc { + return func(title, message string) ([]byte, error) { + pri, _ := strconv.Atoi(priority) + return json.Marshal(map[string]any{ + "title": title, + "message": message, + "priority": pri, + }) + } +} + func GetProvider(cfg models.AlertConfig) Provider { switch cfg.Type { case "discord": @@ -85,6 +132,35 @@ func GetProvider(cfg models.AlertConfig) Provider { Username: cfg.Settings["username"], Password: cfg.Settings["password"], } + case "telegram": + return &HTTPProvider{ + URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.Settings["token"]), + Payload: telegramPayload(cfg.Settings["chat_id"]), + } + case "pagerduty": + severity := "critical" + if s, ok := cfg.Settings["severity"]; ok && s != "" { + severity = s + } + return &HTTPProvider{ + URL: "https://events.pagerduty.com/v2/enqueue", + Payload: pagerdutyPayload(cfg.Settings["routing_key"], severity), + } + case "pushover": + return &HTTPProvider{ + URL: "https://api.pushover.net/1/messages.json", + Payload: pushoverPayload(cfg.Settings["token"], cfg.Settings["user"]), + } + case "gotify": + priority := "5" + if p, ok := cfg.Settings["priority"]; ok && p != "" { + priority = p + } + serverURL := strings.TrimRight(cfg.Settings["url"], "/") + return &HTTPProvider{ + URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]), + Payload: gotifyPayload(priority), + } default: return nil } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index 348f2c9..35e1c8d 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -101,6 +101,110 @@ func TestNtfyProvider(t *testing.T) { } } +func TestHTTPProviderTelegram(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["chat_id"] != "12345" { + t.Errorf("expected chat_id '12345', got '%s'", received["chat_id"]) + } + if received["text"] != "*Alert*\nDown" { + t.Errorf("unexpected text: %s", received["text"]) + } + if received["parse_mode"] != "Markdown" { + t.Errorf("expected parse_mode 'Markdown', got '%s'", received["parse_mode"]) + } +} + +func TestHTTPProviderPagerDuty(t *testing.T) { + var received map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["routing_key"] != "test-key" { + t.Errorf("expected routing_key 'test-key', got '%v'", received["routing_key"]) + } + if received["event_action"] != "trigger" { + t.Errorf("expected event_action 'trigger', got '%v'", received["event_action"]) + } + payload := received["payload"].(map[string]any) + if payload["summary"] != "Alert: Down" { + t.Errorf("unexpected summary: %v", payload["summary"]) + } + if payload["severity"] != "critical" { + t.Errorf("expected severity 'critical', got '%v'", payload["severity"]) + } +} + +func TestHTTPProviderPushover(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["token"] != "app-tok" { + t.Errorf("expected token 'app-tok', got '%s'", received["token"]) + } + if received["user"] != "user-key" { + t.Errorf("expected user 'user-key', got '%s'", received["user"]) + } + if received["title"] != "Alert" || received["message"] != "Down" { + t.Errorf("unexpected payload: %v", received) + } +} + +func TestHTTPProviderGotify(t *testing.T) { + var received map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["title"] != "Alert" || received["message"] != "Down" { + t.Errorf("unexpected payload: %v", received) + } + if pri, ok := received["priority"].(float64); !ok || pri != 8 { + t.Errorf("expected priority 8, got %v", received["priority"]) + } +} + +func TestGetProviderNewTypes(t *testing.T) { + for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} { + p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{ + "token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost", + }}) + if p == nil { + t.Errorf("GetProvider(%q) returned nil", typ) + } + } +} + func TestGetProviderUnknown(t *testing.T) { p := GetProvider(models.AlertConfig{Type: "unknown"}) if p != nil { diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 11c9bf6..342e1bd 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -23,6 +23,19 @@ type alertFormData struct { NtfyUser string NtfyPass string NtfyPri string + // Telegram + TelegramToken string + TelegramChatID string + // PagerDuty + PagerDutyKey string + PagerDutySeverity string + // Pushover + PushoverToken string + PushoverUser string + // Gotify + GotifyURL string + GotifyToken string + GotifyPriority string } func fmtAlertType(t string) string { @@ -37,6 +50,14 @@ func fmtAlertType(t string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t) case "ntfy": return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t) + case "telegram": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#26A5E4")).Render(t) + case "pagerduty": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#06AC38")).Render(t) + case "pushover": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t) + case "gotify": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t) default: return t } @@ -64,6 +85,26 @@ func fmtAlertConfig(alert struct { return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) } return subtleStyle.Render("—") + case "telegram": + if id := alert.Settings["chat_id"]; id != "" { + return limitStr(fmt.Sprintf("chat:%s", id), 34) + } + return subtleStyle.Render("—") + case "pagerduty": + if key := alert.Settings["routing_key"]; key != "" { + return limitStr(key, 34) + } + return subtleStyle.Render("—") + case "pushover": + if user := alert.Settings["user"]; user != "" { + return limitStr(fmt.Sprintf("user:%s", user), 34) + } + return subtleStyle.Render("—") + case "gotify": + if url := alert.Settings["url"]; url != "" { + return limitStr(url, 34) + } + return subtleStyle.Render("—") default: if val, ok := alert.Settings["url"]; ok { return limitStr(val, 34) @@ -102,8 +143,10 @@ func (m Model) viewAlertsTab() string { func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ - AlertType: "discord", - NtfyPri: "3", + AlertType: "discord", + NtfyPri: "3", + PagerDutySeverity: "critical", + GotifyPriority: "5", } if m.editID > 0 { @@ -128,6 +171,19 @@ func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData.NtfyUser = alert.Settings["username"] m.alertFormData.NtfyPass = alert.Settings["password"] m.alertFormData.NtfyPri = alert.Settings["priority"] + case "telegram": + m.alertFormData.TelegramToken = alert.Settings["token"] + m.alertFormData.TelegramChatID = alert.Settings["chat_id"] + case "pagerduty": + m.alertFormData.PagerDutyKey = alert.Settings["routing_key"] + m.alertFormData.PagerDutySeverity = alert.Settings["severity"] + case "pushover": + m.alertFormData.PushoverToken = alert.Settings["token"] + m.alertFormData.PushoverUser = alert.Settings["user"] + case "gotify": + m.alertFormData.GotifyURL = alert.Settings["url"] + m.alertFormData.GotifyToken = alert.Settings["token"] + m.alertFormData.GotifyPriority = alert.Settings["priority"] } break } @@ -152,6 +208,10 @@ func (m *Model) initAlertHuhForm() tea.Cmd { huh.NewOption("Webhook", "webhook"), huh.NewOption("Email (SMTP)", "email"), huh.NewOption("Ntfy", "ntfy"), + huh.NewOption("Telegram", "telegram"), + huh.NewOption("PagerDuty", "pagerduty"), + huh.NewOption("Pushover", "pushover"), + huh.NewOption("Gotify", "gotify"), ).Value(&m.alertFormData.AlertType), ).Title("Alert Config"), huh.NewGroup( @@ -159,7 +219,8 @@ func (m *Model) initAlertHuhForm() tea.Cmd { Placeholder("https://discord.com/api/webhooks/..."). Value(&m.alertFormData.WebhookURL), ).Title("Webhook").WithHideFunc(func() bool { - return m.alertFormData.AlertType == "email" || m.alertFormData.AlertType == "ntfy" + t := m.alertFormData.AlertType + return t != "discord" && t != "slack" && t != "webhook" }), huh.NewGroup( huh.NewInput().Title("Ntfy Server URL"). @@ -207,6 +268,57 @@ func (m *Model) initAlertHuhForm() tea.Cmd { ).Title("Email Settings").WithHideFunc(func() bool { return m.alertFormData.AlertType != "email" }), + huh.NewGroup( + huh.NewInput().Title("Bot Token"). + Placeholder("123456:ABC-DEF1234..."). + Value(&m.alertFormData.TelegramToken), + huh.NewInput().Title("Chat ID"). + Placeholder("-1001234567890"). + Value(&m.alertFormData.TelegramChatID), + ).Title("Telegram Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "telegram" + }), + huh.NewGroup( + huh.NewInput().Title("Routing Key"). + Placeholder("your-integration-routing-key"). + Value(&m.alertFormData.PagerDutyKey), + huh.NewSelect[string]().Title("Severity"). + Options( + huh.NewOption("Critical", "critical"), + huh.NewOption("Error", "error"), + huh.NewOption("Warning", "warning"), + huh.NewOption("Info", "info"), + ).Value(&m.alertFormData.PagerDutySeverity), + ).Title("PagerDuty Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "pagerduty" + }), + huh.NewGroup( + huh.NewInput().Title("App Token"). + Placeholder("your-pushover-app-token"). + Value(&m.alertFormData.PushoverToken), + huh.NewInput().Title("User Key"). + Placeholder("your-pushover-user-key"). + Value(&m.alertFormData.PushoverUser), + ).Title("Pushover Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "pushover" + }), + huh.NewGroup( + huh.NewInput().Title("Server URL"). + Placeholder("https://gotify.example.com"). + Value(&m.alertFormData.GotifyURL), + huh.NewInput().Title("App Token"). + Placeholder("your-gotify-app-token"). + Value(&m.alertFormData.GotifyToken), + huh.NewSelect[string]().Title("Priority"). + Options( + huh.NewOption("Min (0)", "0"), + huh.NewOption("Low (2)", "2"), + huh.NewOption("Normal (5)", "5"), + huh.NewOption("High (8)", "8"), + ).Value(&m.alertFormData.GotifyPriority), + ).Title("Gotify Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "gotify" + }), ).WithTheme(huh.ThemeDracula()) return m.huhForm.Init() @@ -230,6 +342,19 @@ func (m *Model) submitAlertForm() { settings["priority"] = d.NtfyPri settings["username"] = d.NtfyUser settings["password"] = d.NtfyPass + case "telegram": + settings["token"] = d.TelegramToken + settings["chat_id"] = d.TelegramChatID + case "pagerduty": + settings["routing_key"] = d.PagerDutyKey + settings["severity"] = d.PagerDutySeverity + case "pushover": + settings["token"] = d.PushoverToken + settings["user"] = d.PushoverUser + case "gotify": + settings["url"] = d.GotifyURL + settings["token"] = d.GotifyToken + settings["priority"] = d.GotifyPriority default: settings["url"] = d.WebhookURL } From b7b8aa6f03678587dd0fb804fd1b25763a96872a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 11:26:21 -0400 Subject: [PATCH 14/35] feat(metrics): add Prometheus /metrics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-dependency Prometheus text exposition format. Exposes monitor up/down, latency, status code, check timestamps, pause state, SSL cert expiry, and check counters — all from in-memory state. --- internal/metrics/prometheus.go | 99 +++++++++++++++++++++++++++++ internal/metrics/prometheus_test.go | 96 ++++++++++++++++++++++++++++ internal/server/server.go | 6 +- 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 internal/metrics/prometheus.go create mode 100644 internal/metrics/prometheus_test.go diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go new file mode 100644 index 0000000..24f4faa --- /dev/null +++ b/internal/metrics/prometheus.go @@ -0,0 +1,99 @@ +package metrics + +import ( + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "net/http" + "sort" + "strings" +) + +func Handler(eng *monitor.Engine) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sites := eng.GetAllSites() + sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID }) + + var b strings.Builder + + writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") + for _, s := range sites { + val := 0 + if s.Status == "UP" { + val = 1 + } + writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val)) + } + + writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.") + for _, s := range sites { + writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds()) + } + + writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.") + for _, s := range sites { + if s.Type != "http" { + continue + } + writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode)) + } + + writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.") + for _, s := range sites { + if s.LastCheck.IsZero() { + continue + } + writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix())) + } + + writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).") + for _, s := range sites { + val := 0 + if s.Paused { + val = 1 + } + writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val)) + } + + writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.") + for _, s := range sites { + if !s.HasSSL || s.CertExpiry.IsZero() { + continue + } + writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix())) + } + + writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.") + writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.") + for _, s := range sites { + h, ok := eng.GetHistory(s.ID) + if !ok { + continue + } + writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks)) + writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks)) + } + + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.Write([]byte(b.String())) + } +} + +func labels(s models.Site) string { + return fmt.Sprintf(`id="%d",name="%s",type="%s"`, s.ID, escapeLabelValue(s.Name), s.Type) +} + +func escapeLabelValue(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +func writeHelp(b *strings.Builder, name, typ, help string) { + fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, typ) +} + +func writeGauge(b *strings.Builder, name, labels string, val float64) { + fmt.Fprintf(b, "%s{%s} %g\n", name, labels, val) +} diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go new file mode 100644 index 0000000..7cbf680 --- /dev/null +++ b/internal/metrics/prometheus_test.go @@ -0,0 +1,96 @@ +package metrics + +import ( + "context" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type mockStore struct { + sites []models.Site +} + +func (m *mockStore) Init() error { return nil } +func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil } +func (m *mockStore) AddSite(models.Site) error { return nil } +func (m *mockStore) UpdateSite(models.Site) error { return nil } +func (m *mockStore) UpdateSitePaused(int, bool) error { return nil } +func (m *mockStore) DeleteSite(int) error { return nil } +func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil } +func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil } +func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil } +func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil } +func (m *mockStore) DeleteAlert(int) error { return nil } +func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil } +func (m *mockStore) AddUser(string, string, string) error { return nil } +func (m *mockStore) UpdateUser(int, string, string, string) error { return nil } +func (m *mockStore) DeleteUser(int) error { return nil } +func (m *mockStore) SaveCheck(int, int64, bool) error { return nil } +func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { + return nil, nil +} +func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil } +func (m *mockStore) ImportData(models.Backup) error { return nil } + +func TestMetricsHandler(t *testing.T) { + ms := &mockStore{ + sites: []models.Site{ + {ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30}, + {ID: 2, Name: "DNS Check", Type: "dns", Interval: 60}, + }, + } + eng := monitor.NewEngine(ms) + ctx, cancel := context.WithCancel(context.Background()) + eng.Start(ctx) + time.Sleep(100 * time.Millisecond) + + rec := httptest.NewRecorder() + Handler(eng)(rec, httptest.NewRequest("GET", "/metrics", nil)) + cancel() + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + body := rec.Body.String() + + ct := rec.Header().Get("Content-Type") + if !strings.Contains(ct, "text/plain") { + t.Errorf("expected text/plain content type, got %q", ct) + } + + expected := []string{ + "# HELP upkeep_monitor_up", + "# TYPE upkeep_monitor_up gauge", + `upkeep_monitor_up{id="1",name="Example",type="http"}`, + `upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`, + "# HELP upkeep_monitor_latency_seconds", + "# HELP upkeep_monitor_paused", + "# HELP upkeep_monitor_checks_total", + } + for _, s := range expected { + if !strings.Contains(body, s) { + t.Errorf("missing expected line: %s", s) + } + } +} + +func TestEscapeLabelValue(t *testing.T) { + cases := []struct{ in, want string }{ + {`simple`, `simple`}, + {`has "quotes"`, `has \"quotes\"`}, + {"has\nnewline", `has\nnewline`}, + {`back\slash`, `back\\slash`}, + } + for _, tc := range cases { + got := escapeLabelValue(tc.in) + if got != tc.want { + t.Errorf("escapeLabelValue(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ac26bd2..fdf7f9b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "go-upkeep/internal/importer" + "go-upkeep/internal/metrics" "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/store" @@ -242,7 +243,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) }) - // 6. Status Page + // 6. Prometheus Metrics + mux.HandleFunc("/metrics", metrics.Handler(eng)) + + // 7. Status Page if cfg.EnableStatus { mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { From 9e5bb74c5c636658b7e665d57e7a881418316212 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 15:42:51 -0400 Subject: [PATCH 15/35] feat(tui): expose HTTP method and accepted status codes in monitor form DB fields existed but were never surfaced in the TUI. Adds an HTTP Settings form group with method select (7 methods) and accepted codes input, visible only for HTTP monitors. --- internal/tui/tab_sites.go | 68 +++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 4b6ad00..ad96da5 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -21,20 +21,22 @@ var siteGroupStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#7D56F4")) type siteFormData struct { - Name string - SiteType string - URL string - Interval string - AlertID string - CheckSSL bool - Threshold string - Retries string - Hostname string - Port string - Timeout string - Description string - IgnoreTLS bool - GroupID string + Name string + SiteType string + URL string + Method string + AcceptedCodes string + Interval string + AlertID string + CheckSSL bool + Threshold string + Retries string + Hostname string + Port string + Timeout string + Description string + IgnoreTLS bool + GroupID string } func latencySparkline(latencies []time.Duration, width int) string { @@ -277,13 +279,15 @@ func (m Model) viewSitesTab() string { func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData = &siteFormData{ - SiteType: "http", - Interval: "60", - Threshold: "7", - Retries: "0", - Timeout: "5", - Port: "0", - GroupID: "0", + SiteType: "http", + Method: "GET", + AcceptedCodes: "200-299", + Interval: "60", + Threshold: "7", + Retries: "0", + Timeout: "5", + Port: "0", + GroupID: "0", } if m.editID > 0 { @@ -303,6 +307,8 @@ func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData.Description = site.Description m.siteFormData.IgnoreTLS = site.IgnoreTLS m.siteFormData.GroupID = strconv.Itoa(site.ParentID) + m.siteFormData.Method = site.Method + m.siteFormData.AcceptedCodes = site.AcceptedCodes break } } @@ -432,6 +438,24 @@ func (m *Model) initSiteHuhForm() tea.Cmd { ).Title("Connection").WithHideFunc(func() bool { return m.siteFormData.SiteType == "group" }), + huh.NewGroup( + huh.NewSelect[string]().Title("HTTP Method"). + Options( + huh.NewOption("GET", "GET"), + huh.NewOption("POST", "POST"), + huh.NewOption("PUT", "PUT"), + huh.NewOption("PATCH", "PATCH"), + huh.NewOption("DELETE", "DELETE"), + huh.NewOption("HEAD", "HEAD"), + huh.NewOption("OPTIONS", "OPTIONS"), + ).Value(&m.siteFormData.Method), + huh.NewInput().Title("Accepted Status Codes"). + Placeholder("200-299"). + Description("Ranges (200-299) and singles (301) separated by commas"). + Value(&m.siteFormData.AcceptedCodes), + ).Title("HTTP Settings").WithHideFunc(func() bool { + return m.siteFormData.SiteType != "http" + }), huh.NewGroup( huh.NewConfirm().Title("Monitor SSL Certificate?"). Value(&m.siteFormData.CheckSSL), @@ -503,6 +527,8 @@ func (m *Model) submitSiteForm() { Description: d.Description, IgnoreTLS: d.IgnoreTLS, ParentID: groupID, + Method: d.Method, + AcceptedCodes: d.AcceptedCodes, } if m.editID > 0 { From 5b01b9ee309160919f1a1afd79fec1dd08985707 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 20:40:49 -0400 Subject: [PATCH 16/35] feat(config): add config-as-code YAML import/export Add declarative config-as-code support via YAML files. Monitors and alerts can be exported, version controlled, and applied across instances. - goupkeep export [-o file.yaml] dumps current state - goupkeep apply -f file.yaml creates/updates to match desired state - --dry-run shows planned changes without applying - --prune deletes monitors/alerts not in the YAML - Matching by name, alert references by name, nested group children - CLI refactored to subcommands (apply, export, serve) with backward compat - 24 tests covering apply, export, validation, round-trip idempotency --- cmd/goupkeep/main.go | 111 +++++++- docs/config-as-code.md | 244 +++++++++++++++++ go.mod | 1 + go.sum | 1 + internal/config/apply.go | 392 ++++++++++++++++++++++++++++ internal/config/apply_test.go | 290 ++++++++++++++++++++ internal/config/export.go | 154 +++++++++++ internal/config/export_test.go | 140 ++++++++++ internal/config/types.go | 34 +++ internal/config/validate.go | 88 +++++++ internal/config/validate_test.go | 163 ++++++++++++ internal/metrics/prometheus_test.go | 12 +- internal/store/sqlstore.go | 47 ++++ internal/store/store.go | 6 + 14 files changed, 1674 insertions(+), 9 deletions(-) create mode 100644 docs/config-as-code.md create mode 100644 internal/config/apply.go create mode 100644 internal/config/apply_test.go create mode 100644 internal/config/export.go create mode 100644 internal/config/export_test.go create mode 100644 internal/config/types.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_test.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 5962e37..6d72682 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "go-upkeep/internal/cluster" + "go-upkeep/internal/config" "go-upkeep/internal/importer" "go-upkeep/internal/models" "go-upkeep/internal/monitor" @@ -27,6 +28,102 @@ import ( func main() { log.SetOutput(os.Stderr) + if len(os.Args) >= 2 { + switch os.Args[1] { + case "apply": + runApply(os.Args[2:]) + return + case "export": + runExport(os.Args[2:]) + return + } + } + runServe(os.Args[1:]) +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func openStore(dbType, dsn string) store.Store { + var s store.Store + var err error + if dbType == "postgres" { + s, err = store.NewPostgresStore(dsn) + } else { + s, err = store.NewSQLiteStore(dsn) + } + if err != nil { + fmt.Fprintf(os.Stderr, "database error: %v\n", err) + os.Exit(1) + } + if err := s.Init(); err != nil { + fmt.Fprintf(os.Stderr, "database init error: %v\n", err) + os.Exit(1) + } + return s +} + +func runApply(args []string) { + fs := flag.NewFlagSet("apply", flag.ExitOnError) + filePath := fs.String("f", "", "Path to YAML config file (required)") + dryRun := fs.Bool("dry-run", false, "Show planned changes without applying") + 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) + + if *filePath == "" { + fmt.Fprintln(os.Stderr, "error: -f flag is required") + fs.Usage() + os.Exit(1) + } + + s := openStore(*dbType, *dsn) + + f, err := config.LoadFile(*filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + changes, err := config.Apply(s, f, config.ApplyOpts{ + DryRun: *dryRun, + Prune: *prune, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Print(config.FormatChanges(changes, *dryRun)) +} + +func runExport(args []string) { + fs := flag.NewFlagSet("export", flag.ExitOnError) + 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) + + s := openStore(*dbType, *dsn) + + f, err := config.Export(s) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if err := config.WriteFile(f, *outPath); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func runServe(args []string) { portVal := 23234 dbType := "sqlite" dbDSN := "upkeep.db" @@ -59,7 +156,6 @@ func main() { if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { statusTitle = v } - if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { clusterMode = v } @@ -70,12 +166,13 @@ func main() { clusterKey = v } - port := flag.Int("port", portVal, "SSH Port") - flagDBType := flag.String("db-type", dbType, "Database type") - flagDSN := flag.String("dsn", dbDSN, "Database DSN") - demo := flag.Bool("demo", false, "Seed demo data") - importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file") - flag.Parse() + fs := flag.NewFlagSet("serve", flag.ExitOnError) + port := fs.Int("port", portVal, "SSH Port") + flagDBType := fs.String("db-type", dbType, "Database type") + 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) var s store.Store var dbErr error diff --git a/docs/config-as-code.md b/docs/config-as-code.md new file mode 100644 index 0000000..1c9e8a1 --- /dev/null +++ b/docs/config-as-code.md @@ -0,0 +1,244 @@ +# Config as Code + +Define your monitors and alerts in a YAML file. Version control them, copy them between instances, or spin up a fresh setup in one command. + +## Quick start + +Export what you already have: + +```bash +goupkeep export -o monitors.yaml +``` + +That gives you a working file you can edit and re-apply: + +```bash +goupkeep apply -f monitors.yaml +``` + +That's it. Apply only creates or updates — it won't delete anything unless you tell it to. + +## The YAML file + +Two top-level sections: `alerts` and `monitors`. Alerts go first because monitors reference them by name. + +```yaml +alerts: + - name: Discord Ops + type: discord + settings: + url: https://discord.com/api/webhooks/your/token + + - name: PagerDuty Critical + type: pagerduty + settings: + routing_key: your-integration-key + severity: critical + +monitors: + - name: API + type: http + url: https://api.example.com/health + interval: 30 + alert: Discord Ops + + - name: Production + type: group + alert: PagerDuty Critical + monitors: + - name: Prod Web + type: http + url: https://prod.example.com + interval: 15 + - name: Prod DB + type: port + hostname: db.internal + port: 5432 + interval: 30 +``` + +## Monitor types + +Each type has required fields. Everything else is optional with sensible defaults. + +**http** — polls a URL +```yaml +- name: My API + type: http + url: https://api.example.com/health + interval: 30 +``` + +Optional: `method` (default GET), `accepted_codes` (default 200-299), `timeout`, `check_ssl`, `expiry_threshold` (default 7 days), `max_retries`, `ignore_tls`, `description`, `paused`. + +**ping** — ICMP ping a host +```yaml +- name: Gateway + type: ping + hostname: 10.0.0.1 + interval: 30 +``` + +**port** — check if a port is open +```yaml +- name: SSH Server + type: port + hostname: 10.0.0.1 + port: 22 + interval: 60 +``` + +**dns** — resolve a hostname +```yaml +- name: DNS Check + type: dns + hostname: example.com + dns_resolve_type: A + dns_server: 1.1.1.1 + interval: 60 +``` + +**push** — heartbeat endpoint for cron jobs +```yaml +- name: Nightly Backup + type: push + interval: 86400 +``` + +Push monitors get a token assigned automatically. Hit the push endpoint before the interval expires or it alerts. + +**group** — organize monitors together +```yaml +- name: Production + type: group + monitors: + - name: Web + type: http + url: https://prod.example.com + interval: 15 +``` + +Groups can't nest inside other groups. A group is healthy when all its children are healthy. + +## Alert types + +All 9 providers work in the YAML. The `settings` map is different per type. + +```yaml +# Discord / Slack / Generic Webhook — just a URL +- name: Discord Ops + type: discord + settings: + url: https://discord.com/api/webhooks/your/token + +# Email +- name: Email Oncall + type: email + settings: + host: smtp.example.com + port: "587" + user: oncall@example.com + pass: your-password + from: oncall@example.com + to: team@example.com + +# Ntfy +- name: Ntfy Alerts + type: ntfy + settings: + url: https://ntfy.sh + topic: my-alerts + priority: "4" + +# Telegram +- name: Telegram Ops + type: telegram + settings: + token: "123456:ABC-DEF..." + chat_id: "-1001234567890" + +# PagerDuty +- name: PD Critical + type: pagerduty + settings: + routing_key: your-integration-key + severity: critical + +# Pushover +- name: Pushover + type: pushover + settings: + token: app-token + user: user-key + +# Gotify +- name: Gotify + type: gotify + settings: + url: https://gotify.example.com + token: app-token + priority: "8" +``` + +## Commands + +**Export current state:** +```bash +goupkeep export -o monitors.yaml # to a file +goupkeep export # to stdout +``` + +**Apply a config:** +```bash +goupkeep apply -f monitors.yaml +``` + +**See what would change first:** +```bash +goupkeep apply -f monitors.yaml --dry-run +``` + +**Delete monitors not in the YAML:** +```bash +goupkeep apply -f monitors.yaml --prune +``` + +Without `--prune`, apply never deletes anything. It only creates and updates. + +**Pointing at a different database:** +```bash +goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable" +goupkeep apply -f monitors.yaml -db-type postgres -dsn "..." +``` + +Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too. + +## How apply works + +Monitors and alerts are matched by **name**. Names must be unique across the entire file. + +1. Alerts are resolved first (created or updated) +2. Groups are created next (so children can reference them) +3. Everything else is created or updated +4. If `--prune` is set, anything in the database that's not in the YAML gets deleted + +Apply is idempotent. Run it twice with the same file, second run changes nothing. + +If something fails mid-apply, just fix the issue and run it again. It picks up where it left off. + +## Typical workflow + +```bash +# set up your monitors in the TUI first, then export +goupkeep export -o monitors.yaml + +# commit it +git add monitors.yaml && git commit -m "add monitor config" + +# deploy to another instance +scp monitors.yaml prod-server: +ssh prod-server goupkeep apply -f monitors.yaml + +# or just keep it as a backup you can restore from +goupkeep apply -f monitors.yaml +``` diff --git a/go.mod b/go.mod index 7011fb1..38cc730 100644 --- a/go.mod +++ b/go.mod @@ -57,4 +57,5 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 88b63c7..17c3eca 100644 --- a/go.sum +++ b/go.sum @@ -121,5 +121,6 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/apply.go b/internal/config/apply.go new file mode 100644 index 0000000..2c81abf --- /dev/null +++ b/internal/config/apply.go @@ -0,0 +1,392 @@ +package config + +import ( + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/store" + "reflect" + "strings" +) + +type ApplyOpts struct { + DryRun bool + Prune bool +} + +type Change struct { + Action string + Kind string + Name string + Details string +} + +func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) { + if err := Validate(f); err != nil { + return nil, err + } + + existingAlerts, err := s.GetAllAlerts() + if err != nil { + return nil, fmt.Errorf("load alerts: %w", err) + } + + existingSites, err := s.GetSites() + if err != nil { + return nil, fmt.Errorf("load sites: %w", err) + } + + existingAlertsByName := make(map[string]models.AlertConfig, len(existingAlerts)) + for _, a := range existingAlerts { + existingAlertsByName[a.Name] = a + } + + existingSitesByName := make(map[string]models.Site, len(existingSites)) + for _, s := range existingSites { + existingSitesByName[s.Name] = s + } + + var changes []Change + + alertMap := make(map[string]int) + for _, ea := range existingAlerts { + alertMap[ea.Name] = ea.ID + } + + desiredAlertNames := make(map[string]bool, len(f.Alerts)) + for _, a := range f.Alerts { + desiredAlertNames[a.Name] = true + existing, exists := existingAlertsByName[a.Name] + if !exists { + changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type}) + if !opts.DryRun { + id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings) + if err != nil { + return changes, fmt.Errorf("create alert %q: %w", a.Name, err) + } + alertMap[a.Name] = id + } + } else { + alertMap[a.Name] = existing.ID + if diff := diffAlert(existing, a); diff != "" { + changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff}) + if !opts.DryRun { + if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil { + return changes, fmt.Errorf("update alert %q: %w", a.Name, err) + } + } + } + } + } + + desiredMonitorNames := make(map[string]bool) + collectMonitorNames(f.Monitors, desiredMonitorNames) + + var groups []Monitor + var topLevel []Monitor + for _, m := range f.Monitors { + if m.Type == "group" { + groups = append(groups, m) + } else { + topLevel = append(topLevel, m) + } + } + + groupMap := make(map[string]int) + for _, g := range groups { + alertID, err := resolveAlertID(alertMap, g.Alert) + if err != nil { + return changes, fmt.Errorf("monitor %q: %w", g.Name, err) + } + site := monitorToSite(g, alertID, 0) + existing, exists := existingSitesByName[g.Name] + if !exists { + changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"}) + if !opts.DryRun { + id, err := s.AddSiteReturningID(site) + if err != nil { + return changes, fmt.Errorf("create group %q: %w", g.Name, err) + } + groupMap[g.Name] = id + } + } else { + groupMap[g.Name] = existing.ID + site.ID = existing.ID + if diff := diffSite(normalizeSite(existing), site); diff != "" { + changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff}) + if !opts.DryRun { + if err := s.UpdateSite(site); err != nil { + return changes, fmt.Errorf("update group %q: %w", g.Name, err) + } + } + } + } + } + + for _, g := range groups { + parentID := groupMap[g.Name] + for _, child := range g.Monitors { + c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun) + if err != nil { + return changes, err + } + changes = append(changes, c...) + } + } + + for _, m := range topLevel { + c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun) + if err != nil { + return changes, err + } + changes = append(changes, c...) + } + + if opts.Prune { + var childDeletes []Change + var groupDeletes []Change + for _, es := range existingSites { + if desiredMonitorNames[es.Name] { + continue + } + c := Change{Action: "delete", Kind: "monitor", Name: es.Name, Details: es.Type} + if es.Type == "group" { + groupDeletes = append(groupDeletes, c) + } else { + childDeletes = append(childDeletes, c) + } + if !opts.DryRun { + if err := s.DeleteSite(es.ID); err != nil { + return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err) + } + } + } + changes = append(changes, childDeletes...) + changes = append(changes, groupDeletes...) + + for _, ea := range existingAlerts { + if desiredAlertNames[ea.Name] { + continue + } + changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type}) + if !opts.DryRun { + if err := s.DeleteAlert(ea.ID); err != nil { + return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err) + } + } + } + } + + return changes, nil +} + +func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, 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) + } + site := monitorToSite(m, alertID, parentID) + + var changes []Change + ex, exists := existing[m.Name] + if !exists { + changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type}) + if !dryRun { + if _, err := s.AddSiteReturningID(site); err != nil { + return changes, fmt.Errorf("create monitor %q: %w", m.Name, err) + } + } + } else { + site.ID = ex.ID + if diff := diffSite(normalizeSite(ex), site); diff != "" { + changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff}) + if !dryRun { + if err := s.UpdateSite(site); err != nil { + return changes, fmt.Errorf("update monitor %q: %w", m.Name, err) + } + } + } + } + return changes, nil +} + +func resolveAlertID(alertMap map[string]int, name string) (int, error) { + if name == "" { + return 0, nil + } + id, ok := alertMap[name] + if !ok { + return 0, fmt.Errorf("alert %q not found", name) + } + return id, nil +} + +func monitorToSite(m Monitor, alertID, parentID int) models.Site { + s := models.Site{ + Name: m.Name, + Type: m.Type, + URL: m.URL, + Interval: m.Interval, + AlertID: alertID, + ParentID: parentID, + + CheckSSL: m.CheckSSL, + MaxRetries: m.MaxRetries, + Hostname: m.Hostname, + Port: m.Port, + Timeout: m.Timeout, + Description: m.Description, + DNSResolveType: m.DNSResolveType, + DNSServer: m.DNSServer, + IgnoreTLS: m.IgnoreTLS, + Paused: m.Paused, + } + + s.ExpiryThreshold = m.ExpiryThreshold + if s.ExpiryThreshold == 0 { + s.ExpiryThreshold = 7 + } + + s.Method = m.Method + if s.Method == "" { + s.Method = "GET" + } + + s.AcceptedCodes = m.AcceptedCodes + if s.AcceptedCodes == "" { + s.AcceptedCodes = "200-299" + } + + return s +} + +func collectMonitorNames(monitors []Monitor, names map[string]bool) { + for _, m := range monitors { + names[m.Name] = true + collectMonitorNames(m.Monitors, names) + } +} + +func normalizeSite(s models.Site) models.Site { + if s.Method == "" { + s.Method = "GET" + } + if s.AcceptedCodes == "" { + s.AcceptedCodes = "200-299" + } + if s.ExpiryThreshold == 0 { + s.ExpiryThreshold = 7 + } + return s +} + +func diffAlert(existing models.AlertConfig, desired Alert) string { + var diffs []string + if existing.Type != desired.Type { + diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type)) + } + if !reflect.DeepEqual(existing.Settings, desired.Settings) { + diffs = append(diffs, "settings changed") + } + return strings.Join(diffs, ", ") +} + +func diffSite(existing, desired models.Site) string { + var diffs []string + if existing.URL != desired.URL { + diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL)) + } + if existing.Type != desired.Type { + diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type)) + } + if existing.Interval != desired.Interval { + diffs = append(diffs, fmt.Sprintf("interval: %d -> %d", existing.Interval, desired.Interval)) + } + if existing.AlertID != desired.AlertID { + diffs = append(diffs, fmt.Sprintf("alert_id: %d -> %d", existing.AlertID, desired.AlertID)) + } + if existing.CheckSSL != desired.CheckSSL { + diffs = append(diffs, fmt.Sprintf("check_ssl: %v -> %v", existing.CheckSSL, desired.CheckSSL)) + } + if existing.ExpiryThreshold != desired.ExpiryThreshold { + diffs = append(diffs, fmt.Sprintf("expiry_threshold: %d -> %d", existing.ExpiryThreshold, desired.ExpiryThreshold)) + } + if existing.MaxRetries != desired.MaxRetries { + diffs = append(diffs, fmt.Sprintf("max_retries: %d -> %d", existing.MaxRetries, desired.MaxRetries)) + } + if existing.Hostname != desired.Hostname { + diffs = append(diffs, fmt.Sprintf("hostname: %s -> %s", existing.Hostname, desired.Hostname)) + } + if existing.Port != desired.Port { + diffs = append(diffs, fmt.Sprintf("port: %d -> %d", existing.Port, desired.Port)) + } + if existing.Timeout != desired.Timeout { + diffs = append(diffs, fmt.Sprintf("timeout: %d -> %d", existing.Timeout, desired.Timeout)) + } + if existing.Method != desired.Method { + diffs = append(diffs, fmt.Sprintf("method: %s -> %s", existing.Method, desired.Method)) + } + if existing.Description != desired.Description { + diffs = append(diffs, "description changed") + } + if existing.ParentID != desired.ParentID { + diffs = append(diffs, fmt.Sprintf("parent_id: %d -> %d", existing.ParentID, desired.ParentID)) + } + if existing.AcceptedCodes != desired.AcceptedCodes { + diffs = append(diffs, fmt.Sprintf("accepted_codes: %s -> %s", existing.AcceptedCodes, desired.AcceptedCodes)) + } + if existing.DNSResolveType != desired.DNSResolveType { + diffs = append(diffs, fmt.Sprintf("dns_resolve_type: %s -> %s", existing.DNSResolveType, desired.DNSResolveType)) + } + if existing.DNSServer != desired.DNSServer { + diffs = append(diffs, fmt.Sprintf("dns_server: %s -> %s", existing.DNSServer, desired.DNSServer)) + } + if existing.IgnoreTLS != desired.IgnoreTLS { + diffs = append(diffs, fmt.Sprintf("ignore_tls: %v -> %v", existing.IgnoreTLS, desired.IgnoreTLS)) + } + if existing.Paused != desired.Paused { + diffs = append(diffs, fmt.Sprintf("paused: %v -> %v", existing.Paused, desired.Paused)) + } + return strings.Join(diffs, ", ") +} + +func FormatChanges(changes []Change, dryRun bool) string { + var b strings.Builder + if dryRun { + b.WriteString("Dry run — no changes applied.\n\n") + } + + if len(changes) == 0 { + b.WriteString("No changes needed. State is up to date.\n") + return b.String() + } + + creates, updates, deletes := 0, 0, 0 + for _, c := range changes { + var prefix string + switch c.Action { + case "create": + prefix = " + create" + creates++ + case "update": + prefix = " ~ update" + updates++ + case "delete": + prefix = " - delete" + deletes++ + } + line := fmt.Sprintf("%s %s %q", prefix, c.Kind, c.Name) + if c.Details != "" { + line += " (" + c.Details + ")" + } + b.WriteString(line + "\n") + } + + b.WriteString("\n") + if dryRun { + fmt.Fprintf(&b, "Summary: %d to create, %d to update, %d to delete\n", creates, updates, deletes) + } else { + total := creates + updates + deletes + fmt.Fprintf(&b, "Applied %d changes (%d created, %d updated, %d deleted)\n", total, creates, updates, deletes) + } + return b.String() +} diff --git a/internal/config/apply_test.go b/internal/config/apply_test.go new file mode 100644 index 0000000..824fd61 --- /dev/null +++ b/internal/config/apply_test.go @@ -0,0 +1,290 @@ +package config + +import ( + "go-upkeep/internal/models" + "go-upkeep/internal/store" + "strings" + "testing" +) + +func newTestStore(t *testing.T) store.Store { + t.Helper() + s, err := store.NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("NewSQLiteStore: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + return s +} + +func TestApplyCreateFromScratch(t *testing.T) { + s := newTestStore(t) + f := &File{ + Alerts: []Alert{ + {Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}}, + }, + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"}, + {Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30}, + }, + } + + changes, err := Apply(s, f, ApplyOpts{}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + creates := 0 + for _, c := range changes { + if c.Action == "create" { + creates++ + } + } + if creates != 3 { + t.Fatalf("expected 3 creates, got %d", creates) + } + + sites, _ := s.GetSites() + if len(sites) != 2 { + t.Fatalf("expected 2 sites, got %d", len(sites)) + } + + alerts, _ := s.GetAllAlerts() + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } +} + +func TestApplyIdempotent(t *testing.T) { + s := newTestStore(t) + f := &File{ + Alerts: []Alert{ + {Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}}, + }, + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"}, + }, + } + + if _, err := Apply(s, f, ApplyOpts{}); err != nil { + t.Fatalf("first Apply: %v", err) + } + + changes, err := Apply(s, f, ApplyOpts{}) + if err != nil { + t.Fatalf("second Apply: %v", err) + } + + if len(changes) != 0 { + t.Fatalf("expected 0 changes on second apply, got %d: %+v", len(changes), changes) + } +} + +func TestApplyUpdate(t *testing.T) { + s := newTestStore(t) + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30}, + }, + } + + if _, err := Apply(s, f, ApplyOpts{}); err != nil { + t.Fatalf("first Apply: %v", err) + } + + f.Monitors[0].Interval = 60 + changes, err := Apply(s, f, ApplyOpts{}) + if err != nil { + t.Fatalf("second Apply: %v", err) + } + + if len(changes) != 1 || changes[0].Action != "update" { + t.Fatalf("expected 1 update, got %+v", changes) + } + + sites, _ := s.GetSites() + if sites[0].Interval != 60 { + t.Fatalf("expected interval 60, got %d", sites[0].Interval) + } +} + +func TestApplyPrune(t *testing.T) { + s := newTestStore(t) + s.AddSite(models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + + f := &File{ + Monitors: []Monitor{ + {Name: "Keep", Type: "http", URL: "https://keep.com", Interval: 30}, + }, + } + + changes, err := Apply(s, f, ApplyOpts{Prune: true}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + deleteCount := 0 + for _, c := range changes { + if c.Action == "delete" { + deleteCount++ + } + } + if deleteCount != 1 { + t.Fatalf("expected 1 delete, got %d", deleteCount) + } + + sites, _ := s.GetSites() + if len(sites) != 1 || sites[0].Name != "Keep" { + t.Fatalf("expected only 'Keep', got %+v", sites) + } +} + +func TestApplyDryRun(t *testing.T) { + s := newTestStore(t) + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30}, + }, + } + + changes, err := Apply(s, f, ApplyOpts{DryRun: true}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + if len(changes) != 1 || changes[0].Action != "create" { + t.Fatalf("expected 1 create in dry-run, got %+v", changes) + } + + sites, _ := s.GetSites() + if len(sites) != 0 { + t.Fatalf("expected 0 sites after dry-run, got %d", len(sites)) + } +} + +func TestApplyGroupHierarchy(t *testing.T) { + s := newTestStore(t) + f := &File{ + Monitors: []Monitor{ + { + Name: "Prod", Type: "group", + Monitors: []Monitor{ + {Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15}, + {Name: "Prod DB", Type: "port", Hostname: "db.internal", Port: 5432, Interval: 30}, + }, + }, + }, + } + + changes, err := Apply(s, f, ApplyOpts{}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + if len(changes) != 3 { + t.Fatalf("expected 3 creates, got %d", len(changes)) + } + + sites, _ := s.GetSites() + var group models.Site + for _, s := range sites { + if s.Type == "group" { + group = s + break + } + } + + if group.ID == 0 { + t.Fatal("group not found") + } + + childCount := 0 + for _, s := range sites { + if s.ParentID == group.ID { + childCount++ + } + } + if childCount != 2 { + t.Fatalf("expected 2 children, got %d", childCount) + } +} + +func TestApplyAlertReference(t *testing.T) { + s := newTestStore(t) + f := &File{ + Alerts: []Alert{ + {Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}}, + }, + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"}, + }, + } + + if _, err := Apply(s, f, ApplyOpts{}); err != nil { + t.Fatalf("Apply: %v", err) + } + + sites, _ := s.GetSites() + alerts, _ := s.GetAllAlerts() + + if sites[0].AlertID != alerts[0].ID { + t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID) + } +} + +func TestApplyInvalidAlertRef(t *testing.T) { + s := newTestStore(t) + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Nonexistent"}, + }, + } + + _, err := Apply(s, f, ApplyOpts{}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected alert not found error, got %v", err) + } +} + +func TestApplyDuplicateNames(t *testing.T) { + s := newTestStore(t) + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://a.com", Interval: 30}, + {Name: "Web", Type: "http", URL: "https://b.com", Interval: 30}, + }, + } + + _, err := Apply(s, f, ApplyOpts{}) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate error, got %v", err) + } +} + +func TestApplyExistingAlertReference(t *testing.T) { + s := newTestStore(t) + s.AddAlert("Existing", "webhook", map[string]string{"url": "https://example.com"}) + + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Existing"}, + }, + } + + changes, err := Apply(s, f, ApplyOpts{}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + if len(changes) != 1 || changes[0].Action != "create" { + t.Fatalf("expected 1 create, got %+v", changes) + } + + sites, _ := s.GetSites() + if sites[0].AlertID == 0 { + t.Fatal("expected non-zero alert_id for existing alert reference") + } +} diff --git a/internal/config/export.go b/internal/config/export.go new file mode 100644 index 0000000..a6d182d --- /dev/null +++ b/internal/config/export.go @@ -0,0 +1,154 @@ +package config + +import ( + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/store" + "os" + "sort" + + "gopkg.in/yaml.v3" +) + +func Export(s store.Store) (*File, error) { + dbAlerts, err := s.GetAllAlerts() + if err != nil { + return nil, fmt.Errorf("load alerts: %w", err) + } + + dbSites, err := s.GetSites() + if err != nil { + return nil, fmt.Errorf("load sites: %w", err) + } + + alertIDToName := make(map[int]string, len(dbAlerts)) + var yamlAlerts []Alert + for _, a := range dbAlerts { + alertIDToName[a.ID] = a.Name + yamlAlerts = append(yamlAlerts, Alert{ + Name: a.Name, + Type: a.Type, + Settings: a.Settings, + }) + } + + groups := make(map[int]models.Site) + children := make(map[int][]models.Site) + var topLevel []models.Site + + for _, s := range dbSites { + switch { + case s.Type == "group": + groups[s.ID] = s + case s.ParentID > 0: + children[s.ParentID] = append(children[s.ParentID], s) + default: + topLevel = append(topLevel, s) + } + } + + var yamlMonitors []Monitor + + groupIDs := make([]int, 0, len(groups)) + for id := range groups { + groupIDs = append(groupIDs, id) + } + sort.Ints(groupIDs) + + for _, gid := range groupIDs { + g := groups[gid] + ym := siteToMonitor(g, alertIDToName) + kids := children[gid] + sort.Slice(kids, func(i, j int) bool { return kids[i].ID < kids[j].ID }) + for _, child := range kids { + ym.Monitors = append(ym.Monitors, siteToMonitor(child, alertIDToName)) + } + yamlMonitors = append(yamlMonitors, ym) + } + + sort.Slice(topLevel, func(i, j int) bool { return topLevel[i].ID < topLevel[j].ID }) + for _, s := range topLevel { + yamlMonitors = append(yamlMonitors, siteToMonitor(s, alertIDToName)) + } + + return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil +} + +func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor { + m := Monitor{ + Name: s.Name, + Type: s.Type, + Interval: s.Interval, + } + + if s.AlertID > 0 { + if name, ok := alertIDToName[s.AlertID]; ok { + m.Alert = name + } + } + + if s.URL != "" { + m.URL = s.URL + } + if s.Hostname != "" { + m.Hostname = s.Hostname + } + if s.Port != 0 { + m.Port = s.Port + } + if s.Timeout != 0 { + m.Timeout = s.Timeout + } + if s.Description != "" { + m.Description = s.Description + } + if s.DNSResolveType != "" { + m.DNSResolveType = s.DNSResolveType + } + if s.DNSServer != "" { + m.DNSServer = s.DNSServer + } + + if s.Method != "" && s.Method != "GET" { + m.Method = s.Method + } + if s.AcceptedCodes != "" && s.AcceptedCodes != "200-299" { + m.AcceptedCodes = s.AcceptedCodes + } + if s.ExpiryThreshold != 0 && s.ExpiryThreshold != 7 { + m.ExpiryThreshold = s.ExpiryThreshold + } + if s.MaxRetries != 0 { + m.MaxRetries = s.MaxRetries + } + + m.CheckSSL = s.CheckSSL + m.IgnoreTLS = s.IgnoreTLS + m.Paused = s.Paused + + return m +} + +func WriteFile(f *File, path string) error { + data, err := yaml.Marshal(f) + if err != nil { + return fmt.Errorf("marshal yaml: %w", err) + } + if path == "-" || path == "" { + _, err = os.Stdout.Write(data) + return err + } + return os.WriteFile(path, data, 0644) +} + +func LoadFile(path string) (*File, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var f File + if err := yaml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &f, nil +} diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..16b5534 --- /dev/null +++ b/internal/config/export_test.go @@ -0,0 +1,140 @@ +package config + +import ( + "go-upkeep/internal/models" + "testing" +) + +func TestExportEmpty(t *testing.T) { + s := newTestStore(t) + f, err := Export(s) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(f.Alerts) != 0 || len(f.Monitors) != 0 { + t.Fatalf("expected empty file, got %d alerts %d monitors", len(f.Alerts), len(f.Monitors)) + } +} + +func TestExportAlertNames(t *testing.T) { + s := newTestStore(t) + s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"}) + alerts, _ := s.GetAllAlerts() + s.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + + f, err := Export(s) + if err != nil { + t.Fatalf("Export: %v", err) + } + + if len(f.Monitors) != 1 { + t.Fatalf("expected 1 monitor, got %d", len(f.Monitors)) + } + if f.Monitors[0].Alert != "Discord" { + t.Fatalf("expected alert name 'Discord', got %q", f.Monitors[0].Alert) + } +} + +func TestExportGroupHierarchy(t *testing.T) { + s := newTestStore(t) + groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + s.AddSite(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(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + + f, err := Export(s) + if err != nil { + t.Fatalf("Export: %v", err) + } + + if len(f.Monitors) != 2 { + t.Fatalf("expected 2 top-level monitors, got %d", len(f.Monitors)) + } + + var group *Monitor + for i := range f.Monitors { + if f.Monitors[i].Type == "group" { + group = &f.Monitors[i] + break + } + } + if group == nil { + t.Fatal("group not found in export") + } + if len(group.Monitors) != 1 { + t.Fatalf("expected 1 child in group, got %d", len(group.Monitors)) + } + if group.Monitors[0].Name != "Prod Web" { + t.Fatalf("expected child 'Prod Web', got %q", group.Monitors[0].Name) + } +} + +func TestExportOmitsDefaults(t *testing.T) { + s := newTestStore(t) + s.AddSite(models.Site{ + Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, + Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7, + }) + + f, err := Export(s) + if err != nil { + t.Fatalf("Export: %v", err) + } + + m := f.Monitors[0] + if m.Method != "" { + t.Errorf("expected empty method (default omitted), got %q", m.Method) + } + if m.AcceptedCodes != "" { + t.Errorf("expected empty accepted_codes (default omitted), got %q", m.AcceptedCodes) + } + if m.ExpiryThreshold != 0 { + t.Errorf("expected 0 expiry_threshold (default omitted), got %d", m.ExpiryThreshold) + } +} + +func TestExportRoundTrip(t *testing.T) { + s1 := newTestStore(t) + s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"}) + alerts, _ := s1.GetAllAlerts() + s1.AddSite(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(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) + + exported, err := Export(s1) + if err != nil { + t.Fatalf("Export: %v", err) + } + + s2 := newTestStore(t) + changes, err := Apply(s2, exported, ApplyOpts{}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + + creates := 0 + for _, c := range changes { + if c.Action == "create" { + creates++ + } + } + if creates != 3 { + t.Fatalf("expected 3 creates, got %d", creates) + } + + reexported, err := Export(s2) + if err != nil { + t.Fatalf("re-Export: %v", err) + } + + if len(reexported.Alerts) != len(exported.Alerts) { + t.Fatalf("alert count mismatch: %d vs %d", len(reexported.Alerts), len(exported.Alerts)) + } + if len(reexported.Monitors) != len(exported.Monitors) { + t.Fatalf("monitor count mismatch: %d vs %d", len(reexported.Monitors), len(exported.Monitors)) + } + + for i, m := range reexported.Monitors { + if m.Name != exported.Monitors[i].Name { + t.Errorf("monitor %d name mismatch: %q vs %q", i, m.Name, exported.Monitors[i].Name) + } + } +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..ed0895f --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,34 @@ +package config + +type File struct { + Alerts []Alert `yaml:"alerts,omitempty"` + Monitors []Monitor `yaml:"monitors,omitempty"` +} + +type Alert struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Settings map[string]string `yaml:"settings"` +} + +type Monitor struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + URL string `yaml:"url,omitempty"` + Interval int `yaml:"interval,omitempty"` + Alert string `yaml:"alert,omitempty"` + CheckSSL bool `yaml:"check_ssl,omitempty"` + ExpiryThreshold int `yaml:"expiry_threshold,omitempty"` + MaxRetries int `yaml:"max_retries,omitempty"` + Hostname string `yaml:"hostname,omitempty"` + Port int `yaml:"port,omitempty"` + Timeout int `yaml:"timeout,omitempty"` + Method string `yaml:"method,omitempty"` + Description string `yaml:"description,omitempty"` + AcceptedCodes string `yaml:"accepted_codes,omitempty"` + DNSResolveType string `yaml:"dns_resolve_type,omitempty"` + DNSServer string `yaml:"dns_server,omitempty"` + IgnoreTLS bool `yaml:"ignore_tls,omitempty"` + Paused bool `yaml:"paused,omitempty"` + Monitors []Monitor `yaml:"monitors,omitempty"` +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..e9a2f2d --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,88 @@ +package config + +import "fmt" + +var validMonitorTypes = map[string]bool{ + "http": true, + "push": true, + "ping": true, + "port": true, + "dns": true, + "group": true, +} + +func Validate(f *File) error { + alertNames := make(map[string]bool, len(f.Alerts)) + for _, a := range f.Alerts { + if a.Name == "" { + return fmt.Errorf("alert missing name") + } + if alertNames[a.Name] { + return fmt.Errorf("duplicate alert name %q", a.Name) + } + alertNames[a.Name] = true + if a.Type == "" { + return fmt.Errorf("alert %q: missing type", a.Name) + } + } + + monitorNames := make(map[string]bool) + for _, m := range f.Monitors { + if err := validateMonitor(m, monitorNames, false); err != nil { + return err + } + } + return nil +} + +func validateMonitor(m Monitor, names map[string]bool, nested bool) error { + if m.Name == "" { + return fmt.Errorf("monitor missing name") + } + if names[m.Name] { + return fmt.Errorf("duplicate monitor name %q", m.Name) + } + names[m.Name] = true + + if !validMonitorTypes[m.Type] { + return fmt.Errorf("monitor %q: invalid type %q", m.Name, m.Type) + } + + if m.Type == "group" && nested { + return fmt.Errorf("monitor %q: groups cannot be nested inside other groups", m.Name) + } + + switch m.Type { + case "http": + if m.URL == "" { + return fmt.Errorf("monitor %q: url is required for type http", m.Name) + } + case "ping": + if m.Hostname == "" { + return fmt.Errorf("monitor %q: hostname is required for type ping", m.Name) + } + case "port": + if m.Hostname == "" { + return fmt.Errorf("monitor %q: hostname is required for type port", m.Name) + } + if m.Port == 0 { + return fmt.Errorf("monitor %q: port is required for type port", m.Name) + } + case "dns": + if m.Hostname == "" { + return fmt.Errorf("monitor %q: hostname is required for type dns", m.Name) + } + } + + if m.Type == "group" { + for _, child := range m.Monitors { + if err := validateMonitor(child, names, true); err != nil { + return err + } + } + } else if len(m.Monitors) > 0 { + return fmt.Errorf("monitor %q: only groups can have nested monitors", m.Name) + } + + return nil +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..fdcfd42 --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,163 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidateDuplicateAlertNames(t *testing.T) { + f := &File{ + Alerts: []Alert{ + {Name: "A", Type: "discord"}, + {Name: "A", Type: "slack"}, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "duplicate alert name") { + t.Fatalf("expected duplicate alert error, got %v", err) + } +} + +func TestValidateDuplicateMonitorNames(t *testing.T) { + f := &File{ + Monitors: []Monitor{ + {Name: "M", Type: "http", URL: "https://example.com"}, + {Name: "M", Type: "ping", Hostname: "10.0.0.1"}, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") { + t.Fatalf("expected duplicate monitor error, got %v", err) + } +} + +func TestValidateDuplicateNameAcrossGroups(t *testing.T) { + f := &File{ + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com"}, + { + Name: "Prod", Type: "group", + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://prod.example.com"}, + }, + }, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") { + t.Fatalf("expected duplicate name across group, got %v", err) + } +} + +func TestValidateNestedGroupReject(t *testing.T) { + f := &File{ + Monitors: []Monitor{ + { + Name: "Outer", Type: "group", + Monitors: []Monitor{ + {Name: "Inner", Type: "group"}, + }, + }, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "cannot be nested") { + t.Fatalf("expected nested group error, got %v", err) + } +} + +func TestValidateRequiredFields(t *testing.T) { + tests := []struct { + name string + monitor Monitor + wantErr string + }{ + {"http no url", Monitor{Name: "A", Type: "http"}, "url is required"}, + {"ping no hostname", Monitor{Name: "A", Type: "ping"}, "hostname is required"}, + {"port no hostname", Monitor{Name: "A", Type: "port", Port: 22}, "hostname is required"}, + {"port no port", Monitor{Name: "A", Type: "port", Hostname: "h"}, "port is required"}, + {"dns no hostname", Monitor{Name: "A", Type: "dns"}, "hostname is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &File{Monitors: []Monitor{tt.monitor}} + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestValidateInvalidMonitorType(t *testing.T) { + f := &File{ + Monitors: []Monitor{ + {Name: "A", Type: "ftp"}, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "invalid type") { + t.Fatalf("expected invalid type error, got %v", err) + } +} + +func TestValidateNonGroupWithChildren(t *testing.T) { + f := &File{ + Monitors: []Monitor{ + { + Name: "A", Type: "http", URL: "https://example.com", + Monitors: []Monitor{ + {Name: "B", Type: "ping", Hostname: "h"}, + }, + }, + }, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "only groups") { + t.Fatalf("expected only-groups error, got %v", err) + } +} + +func TestValidateAlertMissingName(t *testing.T) { + f := &File{ + Alerts: []Alert{{Type: "discord"}}, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "missing name") { + t.Fatalf("expected missing name error, got %v", err) + } +} + +func TestValidateAlertMissingType(t *testing.T) { + f := &File{ + Alerts: []Alert{{Name: "A"}}, + } + err := Validate(f) + if err == nil || !strings.Contains(err.Error(), "missing type") { + t.Fatalf("expected missing type error, got %v", err) + } +} + +func TestValidateValidConfig(t *testing.T) { + f := &File{ + Alerts: []Alert{ + {Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}}, + }, + Monitors: []Monitor{ + {Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"}, + {Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30}, + {Name: "SSH", Type: "port", Hostname: "10.0.0.1", Port: 22, Interval: 60}, + {Name: "DNS", Type: "dns", Hostname: "example.com", Interval: 60}, + {Name: "Cron", Type: "push", Interval: 300}, + { + Name: "Prod", Type: "group", + Monitors: []Monitor{ + {Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15}, + }, + }, + }, + } + if err := Validate(f); err != nil { + t.Fatalf("expected valid config, got %v", err) + } +} diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 7cbf680..091a5df 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -34,8 +34,16 @@ func (m *mockStore) SaveCheck(int, int64, bool) error { func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { return nil, nil } -func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil } -func (m *mockStore) ImportData(models.Backup) error { return nil } +func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil } +func (m *mockStore) ImportData(models.Backup) error { return nil } +func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil } +func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) { + return models.AlertConfig{}, nil +} +func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil } +func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) { + return 0, nil +} func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 3142399..8adc020 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -110,6 +110,53 @@ func (s *SQLStore) DeleteSite(id int) error { return nil } +func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { + bf := s.dialect.BoolFalse() + query := fmt.Sprintf( + "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) FROM sites WHERE name = %s", + bf, bf, s.q("?"), + ) + var st models.Site + err := s.db.QueryRow(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, + &st.DNSServer, &st.IgnoreTLS, &st.Paused) + return st, err +} + +func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) { + var a models.AlertConfig + var settingsJSON string + err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + if err != nil { + return a, err + } + json.Unmarshal([]byte(settingsJSON), &a.Settings) + return a, nil +} + +func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) { + if err := s.AddSite(site); err != nil { + return 0, err + } + created, err := s.GetSiteByName(site.Name) + if err != nil { + return 0, err + } + return created.ID, nil +} + +func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) { + if err := s.AddAlert(name, aType, settings); err != nil { + return 0, err + } + created, err := s.GetAlertByName(name) + if err != nil { + return 0, err + } + return created.ID, nil +} + func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) { rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 35afa0b..1ed3c99 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -21,6 +21,12 @@ type Store interface { UpdateAlert(id int, name, aType string, settings map[string]string) error DeleteAlert(id int) error + // Declarative config support + GetSiteByName(name string) (models.Site, error) + GetAlertByName(name string) (models.AlertConfig, error) + AddSiteReturningID(site models.Site) (int, error) + AddAlertReturningID(name, aType string, settings map[string]string) (int, error) + // Users GetAllUsers() ([]models.User, error) AddUser(username, publicKey, role string) error From c80ef4425639cb46dfc3b49752bae091262358cf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 22:02:48 -0400 Subject: [PATCH 17/35] docs: rewrite README, remove upstream references Replace old README that referenced rdgames1000 Docker images and goupkeep.org docs. New README reflects current feature set and credits the original project as the fork source. --- README.md | 105 +++++++++++++++++++++++++++--------------------------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e7424e1..291c2de 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,63 @@ # Go-Upkeep -![Go Version](https://img.shields.io/badge/go-1.23-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Docker](https://img.shields.io/docker/pulls/rdgames1000/go-upkeep) +Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`. -**Go-Upkeep** is a self-hosted infrastructure monitor with a retro-futuristic TUI accessible via SSH. It supports High Availability, Push Monitoring, and Alerting. +Originally forked from [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). This is an independent fork with significant additions. -* 🌐 **Full Documentation:** [goupkeep.org/docs](https://goupkeep.org/docs) -* 🐳 **Docker Hub:** [rdgames1000/go-upkeep](https://hub.docker.com/r/rdgames1000/go-upkeep) +## What it does ---- +- **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups +- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify +- **Config as code**: define monitors in YAML, apply declaratively, version control your setup +- **HA clustering**: leader/follower with automatic failover +- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards +- **Public status page**: HTML + JSON, toggle with an env var +- **SQLite or Postgres**: SQLite for single-node, Postgres for production +- **Uptime Kuma import**: migrate from Kuma with one command -## 🚀 Key Features +## Quick start -* **SSH Dashboard**: Zero-install client. Manage monitors via `ssh -p 23234 your-server`. -* **Protocols**: - * **HTTP/S**: Active polling with SSL certificate expiration tracking. - * **PUSH**: Heartbeat endpoints for cron jobs/backup scripts. -* **High Availability**: Leader/Follower clustering with automatic failover. -* **Alerting**: Native support for Discord, Slack, Email (SMTP), and Webhooks. -* **Backends**: SQLite (default) or PostgreSQL (production). - ---- - -## 🛠️ Quick Start (Local Dev) - -**Option A: Native Go (Fastest)** ```bash -go mod tidy go run cmd/goupkeep/main.go -# Connect: ssh -p 23234 localhost +ssh -p 23234 localhost ``` -**Option B: Docker Compose (Full Stack)** +Seed some demo data to see it in action: + ```bash -docker compose -f docker-compose.dev.yml up --build +go run cmd/goupkeep/main.go -demo ``` ---- +## Config as code -## 📦 Production Deployment +Export your current monitors: -For critical infrastructure, we recommend Docker Compose. +```bash +goupkeep export -o monitors.yaml +``` -### 1. The Compose File -Create `docker-compose.yml`: +Apply a config file: + +```bash +goupkeep apply -f monitors.yaml +goupkeep apply -f monitors.yaml --dry-run # see what would change +goupkeep apply -f monitors.yaml --prune # delete anything not in the YAML +``` + +See [docs/config-as-code.md](docs/config-as-code.md) for the full reference. + +## Docker ```yaml services: monitor: - image: rdgames1000/go-upkeep:latest - container_name: go-upkeep + build: . restart: unless-stopped - stdin_open: true # Required for initial setup console + stdin_open: true tty: true ports: - - "23234:23234" # SSH - - "8080:8080" # HTTP (Status Page & Push) + - "23234:23234" + - "8080:8080" volumes: - ./data:/data - ./ssh_keys:/app/.ssh @@ -62,28 +65,26 @@ services: - UPKEEP_DB_TYPE=sqlite - UPKEEP_DB_DSN=/data/upkeep.db - UPKEEP_STATUS_ENABLED=true - - UPKEEP_CLUSTER_SECRET=ChangeMeToSomethingSecure + - UPKEEP_CLUSTER_SECRET=change-me ``` -### 2. Initial Setup (Identity Management) -**Important:** V2 stores SSH keys in the database. You must create the first user manually via the console. +First run: attach to the container (`docker attach go-upkeep`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH. -1. Start the stack: `docker compose up -d` -2. Attach to the container: `docker attach go-upkeep` -3. Inside the TUI: - * Press **[Tab]** to select the `Users` tab. - * Press **[n]** to create a user. - * Enter your username and paste your public key (`cat ~/.ssh/id_ed25519.pub`). - * Press **[Enter]** to save. -4. Detach: Press **Ctrl+P** then **Ctrl+Q**. +## Environment variables -### 3. Usage -Connect using your standard SSH client: -```bash -ssh -p 23234 your-server-ip -``` +| Variable | Default | What it does | +|---|---|---| +| `UPKEEP_PORT` | `23234` | SSH server port | +| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) | +| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` | +| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string | +| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page | +| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title | +| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` | +| `UPKEEP_PEER_URL` | | Leader URL for follower nodes | +| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth | +| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | -For advanced setups (Postgres, Clustering, Migration), please consult the [Official Documentation](https://goupkeep.org/docs). +## License -## 📄 License -MIT License. \ No newline at end of file +MIT — see [LICENSE](LICENSE). From ca9faa0acd607f9cff4ed43065fb062c1965fce0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 11:05:06 -0400 Subject: [PATCH 18/35] =?UTF-8?q?feat(cluster):=20add=20distributed=20prob?= =?UTF-8?q?ing=20foundation=20=E2=80=94=20schema,=20models,=20and=20probe?= =?UTF-8?q?=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add node-aware check history and probe registration infrastructure: - ProbeNode model and nodes table (SQLite + Postgres) - node_id column on check_history for multi-source tracking - Store interface: RegisterNode, GetNode, GetAllNodes, DeleteNode, SaveCheckFromNode - Dialect: UpsertNodeSQL (INSERT OR REPLACE / ON CONFLICT) - API endpoints: POST /api/probe/register, GET /api/probe/assignments, POST /api/probe/results - Backward compatible: existing SaveCheck wraps SaveCheckFromNode with empty node_id --- internal/metrics/prometheus_test.go | 6 ++ internal/models/models.go | 9 +++ internal/server/server.go | 91 ++++++++++++++++++++++++++++- internal/store/dialect.go | 1 + internal/store/postgres.go | 12 ++++ internal/store/sqlite.go | 12 ++++ internal/store/sqlstore.go | 45 +++++++++++++- internal/store/store.go | 8 +++ 8 files changed, 181 insertions(+), 3 deletions(-) diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 091a5df..cd86d26 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -44,6 +44,12 @@ func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) { return 0, nil } +func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil } +func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil } +func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil } +func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } +func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } +func (m *mockStore) DeleteNode(string) error { return nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/models/models.go b/internal/models/models.go index cdf4c68..d3ce4ac 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -52,11 +52,20 @@ type User struct { type CheckRecord struct { SiteID int + NodeID string LatencyNs int64 IsUp bool CheckedAt time.Time } +type ProbeNode struct { + ID string + Name string + Region string + LastSeen time.Time + Version string +} + type Backup struct { Sites []Site `json:"sites"` Alerts []AlertConfig `json:"alerts"` diff --git a/internal/server/server.go b/internal/server/server.go index fdf7f9b..18670c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -243,10 +243,97 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) }) - // 6. Prometheus Metrics + // 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) + return + } + if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { + http.Error(w, "Unauthorized", 401) + return + } + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Region string `json:"region"` + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", 400) + return + } + if req.ID == "" { + http.Error(w, "id is required", 400) + 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) + return + } + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + }) + + // 7. Probe Assignment Fetch + mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { + if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { + http.Error(w, "Unauthorized", 401) + return + } + sites := eng.GetAllSites() + var assigned []models.Site + for _, site := range sites { + if site.Paused || site.Type == "push" || site.Type == "group" { + continue + } + assigned = append(assigned, site) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) + }) + + // 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) + return + } + if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { + http.Error(w, "Unauthorized", 401) + return + } + var req struct { + NodeID string `json:"node_id"` + Results []struct { + SiteID int `json:"site_id"` + LatencyNs int64 `json:"latency_ns"` + IsUp bool `json:"is_up"` + } `json:"results"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", 400) + return + } + if req.NodeID == "" { + http.Error(w, "node_id is required", 400) + return + } + for _, result := range req.Results { + if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil { + log.Printf("Failed to save probe result: %v", err) + } + } + s.UpdateNodeLastSeen(req.NodeID) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + }) + + // 9. Prometheus Metrics mux.HandleFunc("/metrics", metrics.Handler(eng)) - // 7. Status Page + // 10. Status Page if cfg.EnableStatus { mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 4e1ba04..f6e35b2 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -10,6 +10,7 @@ type Dialect interface { ResetSequenceOnEmpty(db *sql.DB, table string) ImportWipe(tx *sql.Tx) ImportResetSequences(tx *sql.Tx) + UpsertNodeSQL() string } // rewritePlaceholders converts ? markers to $1, $2, etc. for Postgres. diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 78fcc8d..df3c038 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -44,6 +44,13 @@ func (d *PostgresDialect) CreateTablesSQL() []string { is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, + `CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + region TEXT DEFAULT '', + last_seen TIMESTAMP DEFAULT NOW(), + version TEXT DEFAULT '' + )`, } } @@ -60,9 +67,14 @@ func (d *PostgresDialect) MigrationsSQL() []string { "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", + "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''", } } +func (d *PostgresDialect) UpsertNodeSQL() string { + return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version" +} + func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {} func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index dbeb74d..ab9686a 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -44,6 +44,13 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, + `CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + region TEXT DEFAULT '', + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + version TEXT DEFAULT '' + )`, } } @@ -60,9 +67,14 @@ func (d *SQLiteDialect) MigrationsSQL() []string { "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", + "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", } } +func (d *SQLiteDialect) UpsertNodeSQL() string { + return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)" +} + func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { var count int db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 8adc020..12a3c7f 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -247,7 +247,11 @@ func (s *SQLStore) DeleteUser(id int) error { } func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error { - _, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)"), siteID, latencyNs, isUp) + return s.SaveCheckFromNode(siteID, "", latencyNs, isUp) +} + +func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error { + _, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp) if err != nil { return err } @@ -257,6 +261,45 @@ func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error { return err } +func (s *SQLStore) RegisterNode(node models.ProbeNode) error { + _, err := s.db.Exec(s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version) + return err +} + +func (s *SQLStore) GetNode(id string) (models.ProbeNode, error) { + var n models.ProbeNode + err := s.db.QueryRow(s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id). + Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version) + return n, err +} + +func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) { + rows, err := s.db.Query("SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name") + if err != nil { + return nil, err + } + defer rows.Close() + var nodes []models.ProbeNode + for rows.Next() { + var n models.ProbeNode + if err := rows.Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version); err != nil { + return nodes, err + } + nodes = append(nodes, n) + } + return nodes, rows.Err() +} + +func (s *SQLStore) UpdateNodeLastSeen(id string) error { + _, err := s.db.Exec(s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id) + return err +} + +func (s *SQLStore) DeleteNode(id string) error { + _, err := s.db.Exec(s.q("DELETE FROM nodes WHERE id = ?"), id) + return err +} + func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) { result := make(map[int][]models.CheckRecord) rows, err := s.db.Query(s.q(` diff --git a/internal/store/store.go b/internal/store/store.go index 1ed3c99..1340326 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,8 +35,16 @@ type Store interface { // History SaveCheck(siteID int, latencyNs int64, isUp bool) error + SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) + // Nodes + RegisterNode(node models.ProbeNode) error + GetNode(id string) (models.ProbeNode, error) + GetAllNodes() ([]models.ProbeNode, error) + UpdateNodeLastSeen(id string) error + DeleteNode(id string) error + // Backup & Restore ExportData() (models.Backup, error) ImportData(data models.Backup) error From ca5a42314ff0078e64b2866938b4736619297ffb Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 11:19:57 -0400 Subject: [PATCH 19/35] feat(cluster): add probe execution mode, check extraction, and result aggregation Phase 2 of distributed probing: - Extract check logic into standalone RunCheck() for use by probes - Add probe cluster mode: stateless nodes that fetch assignments, execute checks, and report results to the leader - Add multi-node result aggregation with configurable strategy (any-down, majority-down, all-down) - Leader ingests probe results into engine live state and triggers alerts - New env vars: UPKEEP_NODE_ID, UPKEEP_NODE_NAME, UPKEEP_NODE_REGION, UPKEEP_AGG_STRATEGY - Example docker-compose.probe.yml with leader + 2 regional probes --- cmd/goupkeep/main.go | 41 +++++ docker-compose.probe.yml | 35 +++++ internal/cluster/cluster.go | 2 + internal/cluster/probe.go | 187 ++++++++++++++++++++++ internal/monitor/aggregator.go | 44 ++++++ internal/monitor/checker.go | 218 ++++++++++++++++++++++++++ internal/monitor/monitor.go | 279 ++++++++------------------------- internal/server/server.go | 1 + 8 files changed, 592 insertions(+), 215 deletions(-) create mode 100644 docker-compose.probe.yml create mode 100644 internal/cluster/probe.go create mode 100644 internal/monitor/aggregator.go create mode 100644 internal/monitor/checker.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 6d72682..38dde72 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -166,6 +166,44 @@ func runServe(args []string) { clusterKey = v } + nodeID := os.Getenv("UPKEEP_NODE_ID") + nodeName := os.Getenv("UPKEEP_NODE_NAME") + nodeRegion := os.Getenv("UPKEEP_NODE_REGION") + aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY") + + if clusterMode == "probe" { + if nodeID == "" { + fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode") + os.Exit(1) + } + if clusterPeer == "" { + fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode") + os.Exit(1) + } + + fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-done + cancel() + }() + + if err := cluster.RunProbe(ctx, cluster.ProbeConfig{ + NodeID: nodeID, + NodeName: nodeName, + Region: nodeRegion, + LeaderURL: clusterPeer, + SharedKey: clusterKey, + Interval: 30, + }); err != nil { + fmt.Fprintf(os.Stderr, "Probe error: %v\n", err) + } + return + } + fs := flag.NewFlagSet("serve", flag.ExitOnError) port := fs.Int("port", portVal, "SSH Port") flagDBType := fs.String("db-type", dbType, "Database type") @@ -214,6 +252,9 @@ func runServe(args []string) { if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { eng.SetInsecureSkipVerify(true) } + if aggStrategy != "" { + eng.SetAggStrategy(monitor.AggregationStrategy(aggStrategy)) + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/docker-compose.probe.yml b/docker-compose.probe.yml new file mode 100644 index 0000000..791811f --- /dev/null +++ b/docker-compose.probe.yml @@ -0,0 +1,35 @@ +services: + leader: + build: . + environment: + - UPKEEP_CLUSTER_MODE=leader + - UPKEEP_CLUSTER_SECRET=changeme + - UPKEEP_AGG_STRATEGY=any-down + - UPKEEP_STATUS_ENABLED=true + ports: + - "8080:8080" + - "23234:23234" + + probe-us-east: + build: . + environment: + - UPKEEP_CLUSTER_MODE=probe + - UPKEEP_NODE_ID=us-east-1 + - UPKEEP_NODE_NAME=US East Probe + - UPKEEP_NODE_REGION=us-east + - UPKEEP_PEER_URL=http://leader:8080 + - UPKEEP_CLUSTER_SECRET=changeme + depends_on: + - leader + + probe-eu-west: + build: . + environment: + - UPKEEP_CLUSTER_MODE=probe + - UPKEEP_NODE_ID=eu-west-1 + - UPKEEP_NODE_NAME=EU West Probe + - UPKEEP_NODE_REGION=eu-west + - UPKEEP_PEER_URL=http://leader:8080 + - UPKEEP_CLUSTER_SECRET=changeme + depends_on: + - leader diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index f986c29..03ec751 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -33,6 +33,8 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) { eng.SetActive(false) go runFollowerLoop(ctx, cfg, eng) } + + // "probe" mode is handled directly in main.go before cluster.Start is called } func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) { diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go new file mode 100644 index 0000000..6df0a36 --- /dev/null +++ b/internal/cluster/probe.go @@ -0,0 +1,187 @@ +package cluster + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "log" + "net/http" + "sync" + "time" +) + +type ProbeConfig struct { + NodeID string + NodeName string + Region string + LeaderURL string + SharedKey string + Interval int +} + +func RunProbe(ctx context.Context, cfg ProbeConfig) error { + if cfg.Interval < 10 { + cfg.Interval = 30 + } + + apiClient := &http.Client{Timeout: 10 * time.Second} + strictClient := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, + } + insecureClient := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + } + + if err := probeRegister(ctx, apiClient, cfg); err != nil { + log.Printf("Probe: initial registration failed: %v (will retry)", err) + } + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + sites, err := probeFetchAssignments(ctx, apiClient, cfg) + if err != nil { + log.Printf("Probe: failed to fetch assignments: %v", err) + sleepCtx(ctx, 10*time.Second) + continue + } + + if len(sites) == 0 { + sleepCtx(ctx, time.Duration(cfg.Interval)*time.Second) + continue + } + + results := probeExecuteChecks(ctx, sites, strictClient, insecureClient) + + if len(results) > 0 { + if err := probeReportResults(ctx, apiClient, cfg, results); err != nil { + log.Printf("Probe: failed to report results: %v", err) + } + } + + sleepCtx(ctx, time.Duration(cfg.Interval)*time.Second) + } +} + +func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) error { + body, _ := json.Marshal(map[string]string{ + "id": cfg.NodeID, "name": cfg.NodeName, "region": cfg.Region, "version": "probe", + }) + req, err := http.NewRequestWithContext(ctx, "POST", cfg.LeaderURL+"/api/probe/register", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Upkeep-Secret", cfg.SharedKey) + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("register returned %d", resp.StatusCode) + } + return nil +} + +func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) { + req, err := http.NewRequestWithContext(ctx, "GET", cfg.LeaderURL+"/api/probe/assignments?node_id="+cfg.NodeID, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Upkeep-Secret", cfg.SharedKey) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("assignments returned %d", resp.StatusCode) + } + var result struct { + Sites []models.Site `json:"sites"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Sites, nil +} + +type probeResultItem struct { + SiteID int `json:"site_id"` + LatencyNs int64 `json:"latency_ns"` + IsUp bool `json:"is_up"` +} + +func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client) []probeResultItem { + var mu sync.Mutex + var results []probeResultItem + sem := make(chan struct{}, 10) + var wg sync.WaitGroup + + for _, site := range sites { + select { + case <-ctx.Done(): + break + default: + } + wg.Add(1) + sem <- struct{}{} + go func(s models.Site) { + defer wg.Done() + defer func() { <-sem }() + + cr := monitor.RunCheck(s, strict, insecure, false) + mu.Lock() + results = append(results, probeResultItem{ + SiteID: s.ID, + LatencyNs: cr.LatencyNs, + IsUp: cr.Status == "UP", + }) + mu.Unlock() + }(site) + } + wg.Wait() + return results +} + +func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfig, results []probeResultItem) error { + body, err := json.Marshal(map[string]interface{}{ + "node_id": cfg.NodeID, + "results": results, + }) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "POST", cfg.LeaderURL+"/api/probe/results", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Upkeep-Secret", cfg.SharedKey) + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("results returned %d", resp.StatusCode) + } + fmt.Printf("Probe: reported %d check results\n", len(results)) + return nil +} + +func sleepCtx(ctx context.Context, d time.Duration) { + select { + case <-time.After(d): + case <-ctx.Done(): + } +} diff --git a/internal/monitor/aggregator.go b/internal/monitor/aggregator.go new file mode 100644 index 0000000..88054c8 --- /dev/null +++ b/internal/monitor/aggregator.go @@ -0,0 +1,44 @@ +package monitor + +import "time" + +type AggregationStrategy string + +const ( + AggAnyDown AggregationStrategy = "any-down" + AggMajorityDown AggregationStrategy = "majority-down" + AggAllDown AggregationStrategy = "all-down" +) + +type NodeResult struct { + NodeID string + IsUp bool + LatencyNs int64 + CheckedAt time.Time +} + +func AggregateStatus(results []NodeResult, strategy AggregationStrategy) (isUp bool, avgLatencyNs int64) { + if len(results) == 0 { + return true, 0 + } + + upCount := 0 + var totalLatency int64 + for _, r := range results { + if r.IsUp { + upCount++ + } + totalLatency += r.LatencyNs + } + avgLatencyNs = totalLatency / int64(len(results)) + + switch strategy { + case AggMajorityDown: + isUp = upCount > len(results)/2 + case AggAllDown: + isUp = upCount > 0 + default: + isUp = upCount == len(results) + } + return +} diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go new file mode 100644 index 0000000..be62155 --- /dev/null +++ b/internal/monitor/checker.go @@ -0,0 +1,218 @@ +package monitor + +import ( + "context" + "go-upkeep/internal/models" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" + probing "github.com/prometheus-community/pro-bing" +) + +type CheckResult struct { + SiteID int + Status string // "UP", "DOWN", "SSL EXP" + StatusCode int + LatencyNs int64 + HasSSL bool + CertExpiry time.Time +} + +func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { + switch site.Type { + case "http": + return runHTTPCheck(site, strict, insecure, globalInsecure) + case "ping": + return runPingCheck(site) + case "port": + return runPortCheck(site) + case "dns": + return runDNSCheck(site) + default: + return CheckResult{SiteID: site.ID, Status: "DOWN"} + } +} + +func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { + method := site.Method + if method == "" { + method = "GET" + } + + timeout := siteTimeout(site) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) + if err != nil { + return CheckResult{SiteID: site.ID, Status: "DOWN"} + } + + client := strict + if globalInsecure || site.IgnoreTLS { + client = insecure + } + + start := time.Now() + resp, err := client.Do(req) + latency := time.Since(start) + + result := CheckResult{ + SiteID: site.ID, + Status: "UP", + LatencyNs: latency.Nanoseconds(), + } + + if err != nil { + result.Status = "DOWN" + return result + } + defer resp.Body.Close() + + result.StatusCode = resp.StatusCode + if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { + result.Status = "DOWN" + } + + if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + result.HasSSL = true + cert := resp.TLS.PeerCertificates[0] + result.CertExpiry = cert.NotAfter + if time.Now().After(cert.NotAfter) { + result.Status = "SSL EXP" + } + } + + return result +} + +func runPingCheck(site models.Site) CheckResult { + host := site.Hostname + if host == "" { + host = site.URL + } + + pinger, err := probing.NewPinger(host) + if err != nil { + return CheckResult{SiteID: site.ID, Status: "DOWN"} + } + pinger.Count = 1 + pinger.Timeout = siteTimeout(site) + pinger.SetPrivileged(false) + + start := time.Now() + err = pinger.Run() + latency := time.Since(start) + + if err != nil || pinger.Statistics().PacketsRecv == 0 { + return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} + } + + stats := pinger.Statistics() + return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()} +} + +func runPortCheck(site models.Site) CheckResult { + host := site.Hostname + if host == "" { + host = site.URL + } + addr := net.JoinHostPort(host, strconv.Itoa(site.Port)) + timeout := siteTimeout(site) + + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, timeout) + latency := time.Since(start) + + if err != nil { + return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} + } + conn.Close() + return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} +} + +func runDNSCheck(site models.Site) CheckResult { + host := site.Hostname + if host == "" { + host = site.URL + } + + server := site.DNSServer + if server == "" { + server = "1.1.1.1" + } + if _, _, err := net.SplitHostPort(server); err != nil { + server = net.JoinHostPort(server, "53") + } + + qtype := dns.TypeA + switch site.DNSResolveType { + case "AAAA": + qtype = dns.TypeAAAA + case "MX": + qtype = dns.TypeMX + case "CNAME": + qtype = dns.TypeCNAME + case "TXT": + qtype = dns.TypeTXT + case "NS": + qtype = dns.TypeNS + case "SOA": + qtype = dns.TypeSOA + case "SRV": + qtype = dns.TypeSRV + case "PTR": + qtype = dns.TypePTR + } + + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(host), qtype) + + c := new(dns.Client) + c.Timeout = siteTimeout(site) + + start := time.Now() + r, _, err := c.Exchange(m, server) + latency := time.Since(start) + + if err != nil { + return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} + } + if r.Rcode != dns.RcodeSuccess { + return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds()} + } + return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} +} + +func siteTimeout(site models.Site) time.Duration { + if site.Timeout > 0 { + return time.Duration(site.Timeout) * time.Second + } + return 5 * time.Second +} + +func isCodeAccepted(code int, accepted string) bool { + if accepted == "" { + return code >= 200 && code < 300 + } + for _, part := range strings.Split(accepted, ",") { + part = strings.TrimSpace(part) + if strings.Contains(part, "-") { + bounds := strings.SplitN(part, "-", 2) + lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) + hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) + if err1 == nil && err2 == nil && code >= lo && code <= hi { + return true + } + } else { + if v, err := strconv.Atoi(part); err == nil && code == v { + return true + } + } + } + return false +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 2d2af10..ddabb74 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -7,15 +7,9 @@ import ( "go-upkeep/internal/alert" "go-upkeep/internal/models" "go-upkeep/internal/store" - "net" "net/http" - "strconv" - "strings" "sync" "time" - - "github.com/miekg/dns" - probing "github.com/prometheus-community/pro-bing" ) type Engine struct { @@ -33,6 +27,10 @@ type Engine struct { tokenIndex map[string]int + probeResultsMu sync.RWMutex + probeResults map[int]map[string]NodeResult + aggStrategy AggregationStrategy + db store.Store insecureSkipVerify bool strictClient *http.Client @@ -41,11 +39,13 @@ type Engine struct { func NewEngine(s store.Store) *Engine { return &Engine{ - liveState: make(map[int]models.Site), - histories: make(map[int]*SiteHistory), - tokenIndex: make(map[string]int), - isActive: true, - db: s, + liveState: make(map[int]models.Site), + histories: make(map[int]*SiteHistory), + tokenIndex: make(map[string]int), + probeResults: make(map[int]map[string]NodeResult), + aggStrategy: AggAnyDown, + isActive: true, + db: s, strictClient: &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, }, @@ -310,19 +310,20 @@ func (e *Engine) checkByID(id int) { if !exists || site.Paused { return } + switch site.Type { - case "http": - e.checkHTTP(site) case "push": e.checkPush(site) - case "ping": - e.checkPing(site) - case "port": - e.checkPort(site) - case "dns": - e.checkDNS(site) case "group": e.checkGroup(site) + default: + result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify) + updatedSite := site + updatedSite.HasSSL = result.HasSSL + updatedSite.CertExpiry = result.CertExpiry + updatedSite.Latency = time.Duration(result.LatencyNs) + updatedSite.LastCheck = time.Now() + e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs)) } } @@ -335,61 +336,6 @@ func (e *Engine) checkPush(site models.Site) { } } -func (e *Engine) checkHTTP(site models.Site) { - method := site.Method - if method == "" { - method = "GET" - } - - timeout := siteTimeout(site) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) - if err != nil { - e.handleStatusChange(site, "DOWN", 0, 0) - return - } - - client := e.strictClient - if e.insecureSkipVerify || site.IgnoreTLS { - client = e.insecureClient - } - - start := time.Now() - resp, err := client.Do(req) - latency := time.Since(start) - - rawStatus := "UP" - rawCode := 0 - var certExpiry time.Time - hasSSL := false - - if err != nil { - rawStatus = "DOWN" - } else { - defer resp.Body.Close() - rawCode = resp.StatusCode - if !isCodeAccepted(rawCode, site.AcceptedCodes) { - rawStatus = "DOWN" - } - if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { - hasSSL = true - cert := resp.TLS.PeerCertificates[0] - certExpiry = cert.NotAfter - if time.Now().After(cert.NotAfter) { - rawStatus = "SSL EXP" - } - } - } - updatedSite := site - updatedSite.HasSSL = hasSSL - updatedSite.CertExpiry = certExpiry - updatedSite.Latency = latency - updatedSite.LastCheck = time.Now() - e.handleStatusChange(updatedSite, rawStatus, rawCode, latency) -} - func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) { if !e.IsActive() { return @@ -462,94 +408,6 @@ func (e *Engine) triggerAlert(alertID int, title, message string) { } } -func siteTimeout(site models.Site) time.Duration { - if site.Timeout > 0 { - return time.Duration(site.Timeout) * time.Second - } - return 5 * time.Second -} - -func isCodeAccepted(code int, accepted string) bool { - if accepted == "" { - return code >= 200 && code < 300 - } - for _, part := range strings.Split(accepted, ",") { - part = strings.TrimSpace(part) - if strings.Contains(part, "-") { - bounds := strings.SplitN(part, "-", 2) - lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) - hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) - if err1 == nil && err2 == nil && code >= lo && code <= hi { - return true - } - } else { - if v, err := strconv.Atoi(part); err == nil && code == v { - return true - } - } - } - return false -} - -func (e *Engine) checkPing(site models.Site) { - host := site.Hostname - if host == "" { - host = site.URL - } - - pinger, err := probing.NewPinger(host) - if err != nil { - e.handleStatusChange(site, "DOWN", 0, 0) - e.AddLog(fmt.Sprintf("Ping '%s' resolve failed: %v", site.Name, err)) - return - } - pinger.Count = 1 - pinger.Timeout = siteTimeout(site) - pinger.SetPrivileged(false) - - start := time.Now() - err = pinger.Run() - latency := time.Since(start) - - if err != nil || pinger.Statistics().PacketsRecv == 0 { - updatedSite := site - updatedSite.Latency = latency - updatedSite.LastCheck = time.Now() - e.handleStatusChange(updatedSite, "DOWN", 0, latency) - return - } - - stats := pinger.Statistics() - updatedSite := site - updatedSite.Latency = stats.AvgRtt - updatedSite.LastCheck = time.Now() - e.handleStatusChange(updatedSite, "UP", 0, stats.AvgRtt) -} - -func (e *Engine) checkPort(site models.Site) { - host := site.Hostname - if host == "" { - host = site.URL - } - addr := net.JoinHostPort(host, strconv.Itoa(site.Port)) - timeout := siteTimeout(site) - - start := time.Now() - conn, err := net.DialTimeout("tcp", addr, timeout) - latency := time.Since(start) - - updatedSite := site - updatedSite.Latency = latency - updatedSite.LastCheck = time.Now() - - if err != nil { - e.handleStatusChange(updatedSite, "DOWN", 0, latency) - return - } - conn.Close() - e.handleStatusChange(updatedSite, "UP", 0, latency) -} - func (e *Engine) checkGroup(site models.Site) { e.mu.RLock() status := "UP" @@ -588,63 +446,54 @@ func (e *Engine) checkGroup(site models.Site) { e.mu.Unlock() } -func (e *Engine) checkDNS(site models.Site) { - host := site.Hostname - if host == "" { - host = site.URL +func (e *Engine) SetAggStrategy(strategy AggregationStrategy) { + e.aggStrategy = strategy +} + +func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool) { + e.probeResultsMu.Lock() + if e.probeResults[siteID] == nil { + e.probeResults[siteID] = make(map[string]NodeResult) + } + e.probeResults[siteID][nodeID] = NodeResult{ + NodeID: nodeID, + IsUp: isUp, + LatencyNs: latencyNs, + CheckedAt: time.Now(), + } + results := make([]NodeResult, 0, len(e.probeResults[siteID])) + for _, r := range e.probeResults[siteID] { + results = append(results, r) + } + e.probeResultsMu.Unlock() + + aggUp, avgLatency := AggregateStatus(results, e.aggStrategy) + + e.mu.RLock() + site, exists := e.liveState[siteID] + e.mu.RUnlock() + if !exists { + return } - server := site.DNSServer - if server == "" { - server = "1.1.1.1" + rawStatus := "UP" + if !aggUp { + rawStatus = "DOWN" } - if _, _, err := net.SplitHostPort(server); err != nil { - server = net.JoinHostPort(server, "53") - } - - qtype := dns.TypeA - switch site.DNSResolveType { - case "AAAA": - qtype = dns.TypeAAAA - case "MX": - qtype = dns.TypeMX - case "CNAME": - qtype = dns.TypeCNAME - case "TXT": - qtype = dns.TypeTXT - case "NS": - qtype = dns.TypeNS - case "SOA": - qtype = dns.TypeSOA - case "SRV": - qtype = dns.TypeSRV - case "PTR": - qtype = dns.TypePTR - } - - m := new(dns.Msg) - m.SetQuestion(dns.Fqdn(host), qtype) - - c := new(dns.Client) - c.Timeout = siteTimeout(site) - - start := time.Now() - r, _, err := c.Exchange(m, server) - latency := time.Since(start) updatedSite := site - updatedSite.Latency = latency + updatedSite.Latency = time.Duration(avgLatency) updatedSite.LastCheck = time.Now() - - if err != nil { - e.handleStatusChange(updatedSite, "DOWN", 0, latency) - return - } - - if r.Rcode != dns.RcodeSuccess { - e.handleStatusChange(updatedSite, "DOWN", r.Rcode, latency) - return - } - - e.handleStatusChange(updatedSite, "UP", 0, latency) + e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency)) +} + +func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult { + e.probeResultsMu.RLock() + defer e.probeResultsMu.RUnlock() + src := e.probeResults[siteID] + cp := make(map[string]NodeResult, len(src)) + for k, v := range src { + cp[k] = v + } + return cp } diff --git a/internal/server/server.go b/internal/server/server.go index 18670c2..bd7926b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -325,6 +325,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil { log.Printf("Failed to save probe result: %v", err) } + eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp) } s.UpdateNodeLastSeen(req.NodeID) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) From 0396acdc5980149d2941a82719b462d1bf888e8a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 11:50:16 -0400 Subject: [PATCH 20/35] feat(cluster): add region affinity, Nodes TUI tab, and probe metrics Phase 3 of distributed probing: - Add regions column to sites table for per-monitor probe affinity - Region-filtered probe assignments (empty regions = all probes) - New Nodes TUI tab showing connected probes with status/region/last-seen - Regions input field in site form for configuring probe affinity - Config-as-code support for regions (export/import/diff) - Prometheus upkeep_probe_up metric with per-node labels - Reindex TUI tabs: Sites, Alerts, Logs, Nodes, Users --- internal/config/apply.go | 4 ++ internal/config/export.go | 4 ++ internal/config/types.go | 1 + internal/metrics/prometheus.go | 13 +++++ internal/models/models.go | 1 + internal/server/server.go | 20 +++++++ internal/store/postgres.go | 1 + internal/store/sqlite.go | 1 + internal/store/sqlstore.go | 20 +++---- internal/tui/tab_nodes.go | 96 ++++++++++++++++++++++++++++++++++ internal/tui/tab_sites.go | 7 +++ internal/tui/tui.go | 45 ++++++++++------ 12 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 internal/tui/tab_nodes.go diff --git a/internal/config/apply.go b/internal/config/apply.go index 2c81abf..ca37e36 100644 --- a/internal/config/apply.go +++ b/internal/config/apply.go @@ -239,6 +239,7 @@ func monitorToSite(m Monitor, alertID, parentID int) models.Site { DNSServer: m.DNSServer, IgnoreTLS: m.IgnoreTLS, Paused: m.Paused, + Regions: m.Regions, } s.ExpiryThreshold = m.ExpiryThreshold @@ -346,6 +347,9 @@ func diffSite(existing, desired models.Site) string { if existing.Paused != desired.Paused { diffs = append(diffs, fmt.Sprintf("paused: %v -> %v", existing.Paused, desired.Paused)) } + if existing.Regions != desired.Regions { + diffs = append(diffs, fmt.Sprintf("regions: %s -> %s", existing.Regions, desired.Regions)) + } return strings.Join(diffs, ", ") } diff --git a/internal/config/export.go b/internal/config/export.go index a6d182d..a8cb981 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -126,6 +126,10 @@ func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor { m.IgnoreTLS = s.IgnoreTLS m.Paused = s.Paused + if s.Regions != "" { + m.Regions = s.Regions + } + return m } diff --git a/internal/config/types.go b/internal/config/types.go index ed0895f..8613d7f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -30,5 +30,6 @@ type Monitor struct { DNSServer string `yaml:"dns_server,omitempty"` IgnoreTLS bool `yaml:"ignore_tls,omitempty"` Paused bool `yaml:"paused,omitempty"` + Regions string `yaml:"regions,omitempty"` Monitors []Monitor `yaml:"monitors,omitempty"` } diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index 24f4faa..a85493f 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -74,6 +74,19 @@ func Handler(eng *monitor.Engine) http.HandlerFunc { writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks)) } + writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.") + for _, site := range sites { + probeResults := eng.GetProbeResults(site.ID) + for nodeID, result := range probeResults { + val := 0 + if result.IsUp { + val = 1 + } + nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID)) + writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val)) + } + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Write([]byte(b.String())) } diff --git a/internal/models/models.go b/internal/models/models.go index d3ce4ac..14a97c3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -25,6 +25,7 @@ type Site struct { DNSServer string IgnoreTLS bool Paused bool + Regions string FailureCount int Status string diff --git a/internal/server/server.go b/internal/server/server.go index bd7926b..6f70df1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "sort" + "strings" ) var statusTpl = template.Must(template.New("status").Parse(` @@ -283,12 +284,31 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { http.Error(w, "Unauthorized", 401) return } + nodeID := r.URL.Query().Get("node_id") + var nodeRegion string + if nodeID != "" { + if node, err := s.GetNode(nodeID); err == nil { + nodeRegion = node.Region + } + } sites := eng.GetAllSites() var assigned []models.Site for _, site := range sites { if site.Paused || site.Type == "push" || site.Type == "group" { continue } + if site.Regions != "" && nodeRegion != "" { + matched := false + for _, r := range strings.Split(site.Regions, ",") { + if strings.TrimSpace(r) == nodeRegion { + matched = true + break + } + } + if !matched { + continue + } + } assigned = append(assigned, site) } w.Header().Set("Content-Type", "application/json") diff --git a/internal/store/postgres.go b/internal/store/postgres.go index df3c038..d6e6dbd 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -68,6 +68,7 @@ func (d *PostgresDialect) MigrationsSQL() []string { "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''", + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''", } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index ab9686a..be7ba1d 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -68,6 +68,7 @@ func (d *SQLiteDialect) MigrationsSQL() []string { "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", + "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''", } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 12a3c7f..e8b554a 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -51,7 +51,7 @@ func (s *SQLStore) Init() error { func (s *SQLStore) GetSites() ([]models.Site, error) { bf := s.dialect.BoolFalse() query := fmt.Sprintf( - "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) 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, ) rows, err := s.db.Query(query) @@ -65,7 +65,7 @@ func (s *SQLStore) GetSites() ([]models.Site, error) { 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, - &st.DNSServer, &st.IgnoreTLS, &st.Paused); err != nil { + &st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions); err != nil { return sites, err } sites = append(sites, st) @@ -78,9 +78,9 @@ func (s *SQLStore) AddSite(site models.Site) error { if site.Type == "push" { token = generateToken() } - _, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + _, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions) return err } @@ -90,9 +90,9 @@ func (s *SQLStore) UpdateSite(site models.Site) error { if site.Type == "push" && existingToken == "" { existingToken = generateToken() } - _, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?"), + _, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"), site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, - site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID) + site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID) return err } @@ -113,14 +113,14 @@ func (s *SQLStore) DeleteSite(id int) error { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { bf := s.dialect.BoolFalse() query := fmt.Sprintf( - "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) 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("?"), ) var st models.Site err := s.db.QueryRow(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, - &st.DNSServer, &st.IgnoreTLS, &st.Paused) + &st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions) return st, err } @@ -368,9 +368,9 @@ func (s *SQLStore) ImportData(data models.Backup) error { } } for _, st := range data.Sites { - if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), + if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), 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, st.DNSServer, st.IgnoreTLS, st.Paused); err != nil { + st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil { return err } } diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go new file mode 100644 index 0000000..60f9522 --- /dev/null +++ b/internal/tui/tab_nodes.go @@ -0,0 +1,96 @@ +package tui + +import ( + "fmt" + "go-upkeep/internal/models" + "strings" + "time" +) + +func (m Model) viewNodesTab() string { + if len(m.nodes) == 0 { + return "\n No probe nodes connected." + } + + colWidths := []int{0, 12, 20, 10, 8} + + return m.renderTable( + []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}, + len(m.nodes), + func(start, end int) [][]string { + var rows [][]string + for i := start; i < end; i++ { + node := m.nodes[i] + name := limitStr(node.Name, 20) + if name == "" { + name = node.ID + } + region := node.Region + if region == "" { + region = subtleStyle.Render("—") + } + lastSeen := fmtNodeLastSeen(node.LastSeen) + version := node.Version + if version == "" { + version = subtleStyle.Render("—") + } + status := fmtNodeStatus(node.LastSeen) + rows = append(rows, []string{name, region, lastSeen, version, status}) + } + return rows + }, + colWidths, + nil, + ) +} + +func fmtNodeStatus(lastSeen time.Time) string { + if lastSeen.IsZero() { + return subtleStyle.Render("UNKNOWN") + } + ago := time.Since(lastSeen) + if ago < 60*time.Second { + return specialStyle.Render("ONLINE") + } + if ago < 5*time.Minute { + return warnStyle.Render("STALE") + } + return dangerStyle.Render("OFFLINE") +} + +func fmtNodeLastSeen(t time.Time) string { + if t.IsZero() { + return subtleStyle.Render("never") + } + ago := time.Since(t) + if ago < time.Minute { + return fmt.Sprintf("%ds ago", int(ago.Seconds())) + } + if ago < time.Hour { + return fmt.Sprintf("%dm ago", int(ago.Minutes())) + } + 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 ad96da5..0672cbc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -37,6 +37,7 @@ type siteFormData struct { Description string IgnoreTLS bool GroupID string + Regions string } func latencySparkline(latencies []time.Duration, width int) string { @@ -309,6 +310,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData.GroupID = strconv.Itoa(site.ParentID) m.siteFormData.Method = site.Method m.siteFormData.AcceptedCodes = site.AcceptedCodes + m.siteFormData.Regions = site.Regions break } } @@ -435,6 +437,10 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewInput().Title("Description"). Placeholder("Optional description"). Value(&m.siteFormData.Description), + huh.NewInput().Title("Probe Regions"). + Placeholder("us-east, eu-west (empty = all)"). + Description("Comma-separated regions for distributed probing"). + Value(&m.siteFormData.Regions), ).Title("Connection").WithHideFunc(func() bool { return m.siteFormData.SiteType == "group" }), @@ -529,6 +535,7 @@ func (m *Model) submitSiteForm() { ParentID: groupID, Method: d.Method, AcceptedCodes: d.AcceptedCodes, + Regions: d.Regions, } if m.editID > 0 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 89846a5..f188254 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -80,6 +80,7 @@ type Model struct { sites []models.Site alerts []models.AlertConfig users []models.User + nodes []models.ProbeNode } func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { @@ -131,12 +132,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.refreshData() m.state = stateDashboard - if m.deleteTab == 3 { + if m.deleteTab == 4 { m.state = stateUsers } case "n", "N", "esc": m.state = stateDashboard - if m.deleteTab == 3 { + if m.deleteTab == 4 { m.state = stateUsers } case "ctrl+c": @@ -155,7 +156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg.String() == "esc" { m.huhForm = nil m.state = stateDashboard - if m.currentTab == 3 { + if m.currentTab == 4 { m.state = stateUsers } return m, nil @@ -214,6 +215,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentTab == 1 { listLen = len(m.alerts) } else if m.currentTab == 3 { + listLen = len(m.nodes) + } else if m.currentTab == 4 { listLen = len(m.users) } if msg.Button == tea.MouseButtonWheelUp { @@ -273,6 +276,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { max = len(m.alerts) - 1 } if m.currentTab == 3 { + max = len(m.nodes) - 1 + } + if m.currentTab == 4 { max = len(m.users) - 1 } if m.cursor < max { @@ -291,7 +297,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.currentTab == 1 { m.state = stateFormAlert return m, m.initAlertHuhForm() - } else if m.currentTab == 3 && m.isAdmin { + } else if m.currentTab == 4 && m.isAdmin { m.state = stateFormUser return m, m.initUserHuhForm() } @@ -305,7 +311,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editID = m.alerts[m.cursor].ID m.state = stateFormAlert return m, m.initAlertHuhForm() - } else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 { + } else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 { m.editID = m.users[m.cursor].ID m.state = stateFormUser return m, m.initUserHuhForm() @@ -335,10 +341,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deleteName = m.alerts[m.cursor].Name m.deleteTab = 1 m.state = stateConfirmDelete - } else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 { + } else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 { m.deleteID = m.users[m.cursor].ID m.deleteName = m.users[m.cursor].Username - m.deleteTab = 3 + m.deleteTab = 4 m.state = stateConfirmDelete } } @@ -348,9 +354,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := 3 + tabCount := 4 if m.isAdmin { - tabCount = 4 + tabCount = 5 } for i := 0; i < tabCount; i++ { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { @@ -385,7 +391,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } - if m.currentTab == 3 { + if m.currentTab == 4 { end := m.tableOffset + m.maxTableRows if end > len(m.users) { end = len(m.users) @@ -402,9 +408,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m *Model) switchTab(idx int) { - maxTabs := 2 + maxTabs := 3 if m.isAdmin { - maxTabs = 3 + maxTabs = 4 } if idx > maxTabs { idx = 0 @@ -415,7 +421,7 @@ func (m *Model) switchTab(idx int) { switch idx { case 2: m.state = stateLogs - case 3: + case 4: m.state = stateUsers default: m.state = stateDashboard @@ -473,12 +479,17 @@ func (m *Model) refreshData() { m.users = users } } + if nodes, err := m.store.GetAllNodes(); err == nil { + m.nodes = nodes + } m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) listLen := len(m.sites) if m.currentTab == 1 { listLen = len(m.alerts) } else if m.currentTab == 3 { + listLen = len(m.nodes) + } else if m.currentTab == 4 { listLen = len(m.users) } if listLen > 0 && m.cursor >= listLen { @@ -522,7 +533,7 @@ func (m Model) View() string { kind := "monitor" if m.deleteTab == 1 { kind = "alert" - } else if m.deleteTab == 3 { + } else if m.deleteTab == 4 { kind = "user" } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) @@ -559,7 +570,7 @@ func (m Model) View() string { } func (m Model) viewDashboard() string { - tabs := []string{"Sites", "Alerts", "Logs"} + tabs := []string{"Sites", "Alerts", "Logs", "Nodes"} if m.isAdmin { tabs = append(tabs, "Users") } @@ -587,13 +598,15 @@ func (m Model) viewDashboard() string { case 2: content = m.viewLogsTab() case 3: + content = m.viewNodesTab() + case 4: if m.isAdmin { content = m.viewUsersTab() } } footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit") - if m.currentTab == 3 { + if m.currentTab == 4 { footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") } s := lipgloss.NewStyle().Padding(1, 2) From 769954c8f509c792ce4deec41bfef319934cc1e8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:25:46 -0400 Subject: [PATCH 21/35] feat(tui): add status bar, tab badges, and detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish pass for TUI professionalism: - Status bar replaces generic footer with live stats (UP/DOWN count, online probes) plus contextual key hints - Tab badges show DOWN count on Sites tab and offline count on Nodes tab - Detail panel (press i) shows full monitor info: URL, latency, uptime, SSL, probe results, sparkline — without entering edit mode --- internal/tui/tab_sites.go | 82 +++++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 64 ++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 0672cbc..3f925dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -550,3 +550,85 @@ func (m *Model) submitSiteForm() { } m.state = stateDashboard } + +func (m Model) viewDetailPanel() string { + if m.cursor >= len(m.sites) { + return "" + } + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + + var b strings.Builder + + title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) + b.WriteString(title + "\n\n") + + row := func(label, value string) { + b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) + } + + row("Status", fmtStatus(site.Status, site.Paused)) + row("Type", site.Type) + if site.URL != "" { + row("URL", site.URL) + } + if site.Hostname != "" { + row("Host", site.Hostname) + } + if site.Port > 0 { + row("Port", strconv.Itoa(site.Port)) + } + row("Interval", fmt.Sprintf("%ds", site.Interval)) + row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + row("Latency", fmtLatency(site.Latency)) + row("Uptime", fmtUptime(hist.TotalChecks, hist.UpChecks)) + + if site.Type == "http" { + row("Method", site.Method) + row("Codes", site.AcceptedCodes) + row("SSL", fmtSSL(site)) + if site.IgnoreTLS { + row("TLS Verify", dangerStyle.Render("disabled")) + } + } + + if site.MaxRetries > 0 { + row("Retries", fmtRetries(site)) + } + if site.Regions != "" { + row("Regions", site.Regions) + } + if site.Description != "" { + row("Description", site.Description) + } + if !site.LastCheck.IsZero() { + row("Last Check", site.LastCheck.Format("15:04:05")) + } + + probeResults := m.engine.GetProbeResults(site.ID) + if len(probeResults) > 0 { + b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") + for nodeID, result := range probeResults { + status := specialStyle.Render("UP") + if !result.IsUp { + status = dangerStyle.Render("DN") + } + 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)) + } + } + + b.WriteString("\n") + const sparkWidth = 40 + if site.Type == "push" { + b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) + } else { + b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) + } + + b.WriteString("\n\n") + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f188254..b7af72b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -37,6 +37,7 @@ const ( stateDashboard sessionState = iota stateLogs stateUsers + stateDetail stateFormSite stateFormAlert stateFormUser @@ -247,6 +248,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch m.state { + case stateDetail: + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil case stateDashboard, stateLogs, stateUsers: switch msg.String() { case "q": @@ -330,6 +339,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = m.store.UpdateSitePaused(site.ID, site.Paused) m.refreshData() } + case "i": + if m.currentTab == 0 && len(m.sites) > 0 { + m.state = stateDetail + } case "d", "backspace": if m.currentTab == 0 && len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID @@ -564,13 +577,37 @@ func (m Model) View() string { return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) } return "" + case stateDetail: + return m.viewDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } } func (m Model) viewDashboard() string { - tabs := []string{"Sites", "Alerts", "Logs", "Nodes"} + downCount := 0 + for _, s := range m.sites { + if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { + downCount++ + } + } + offlineNodes := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { + offlineNodes++ + } + } + + sitesLabel := "Sites" + if downCount > 0 { + sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) + } + nodesLabel := "Nodes" + if offlineNodes > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) + } + + tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} if m.isAdmin { tabs = append(tabs, "Users") } @@ -605,10 +642,29 @@ func (m Model) viewDashboard() string { } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit") - if m.currentTab == 4 { - footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + upCount := len(m.sites) - downCount + statusParts := []string{fmt.Sprintf("%d/%d UP", upCount, len(m.sites))} + if len(m.nodes) > 0 { + online := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { + online++ + } + } + statusParts = append(statusParts, fmt.Sprintf("%d probes", online)) } + statusLine := subtleStyle.Render(strings.Join(statusParts, " · ")) + + var keys string + switch m.currentTab { + case 0: + keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + case 4: + keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + default: + keys = "[Tab]Switch [q]Quit" + } + footer := "\n" + statusLine + " " + subtleStyle.Render(keys) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight) From 3bc8e31b89050bc62704df85afc0609076fa908e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:28:09 -0400 Subject: [PATCH 22/35] fix(tui): make status bar and tab badges visible - Tab badges now always show count (Sites (12)), not just on failure - Status bar UP count uses green/red coloring instead of subtle gray --- internal/tui/tui.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b7af72b..18570aa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -598,13 +598,21 @@ func (m Model) viewDashboard() string { } } - sitesLabel := "Sites" + var sitesLabel string if downCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) + } else if len(m.sites) > 0 { + sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) + } else { + sitesLabel = "Sites" } - nodesLabel := "Nodes" + var nodesLabel string if offlineNodes > 0 { nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) + } else if len(m.nodes) > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes)) + } else { + nodesLabel = "Nodes" } tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} @@ -643,7 +651,13 @@ func (m Model) viewDashboard() string { } upCount := len(m.sites) - downCount - statusParts := []string{fmt.Sprintf("%d/%d UP", upCount, len(m.sites))} + var upStr string + if downCount > 0 { + upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + } else { + upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + } + statusParts := []string{upStr} if len(m.nodes) > 0 { online := 0 for _, n := range m.nodes { @@ -653,7 +667,7 @@ func (m Model) viewDashboard() string { } statusParts = append(statusParts, fmt.Sprintf("%d probes", online)) } - statusLine := subtleStyle.Render(strings.Join(statusParts, " · ")) + statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) var keys string switch m.currentTab { From f2ea0dc758d2accb78d818e5754c53b9551c7ffa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:56:09 -0400 Subject: [PATCH 23/35] feat(tui): bordered modals, welcome state, and dynamic name width - Delete confirmation wrapped in rounded border box with danger color - Empty sites view shows styled welcome box with onboarding hint - NAME column width scales with terminal width (13-40 chars) --- internal/tui/tab_sites.go | 28 ++++++++++++++++++++++++---- internal/tui/tui.go | 7 ++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 3f925dc..faab54a 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -195,11 +195,31 @@ func fmtStatus(status string, paused bool) string { } } +func (m Model) nameWidth() int { + w := m.termWidth - 105 + if w < 13 { + w = 13 + } + if w > 40 { + w = 40 + } + return w +} + func (m Model) viewSitesTab() string { const sparkWidth = 20 if len(m.sites) == 0 { - return "\n No sites configured. Press [n] to add one." + welcome := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(1, 3). + Render( + titleStyle.Render("Go-Upkeep") + "\n\n" + + "No monitors configured yet.\n\n" + + subtleStyle.Render("[n] Add your first monitor"), + ) + return "\n" + welcome } colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} @@ -222,7 +242,7 @@ func (m Model) viewSitesTab() string { } rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, m.nameWidth()-2)), "group", fmtStatus(site.Status, site.Paused), subtleStyle.Render("—"), @@ -240,9 +260,9 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, 11) + name = prefix + " " + limitStr(name, m.nameWidth()-2) } else { - name = limitStr(name, 13) + name = limitStr(name, m.nameWidth()) } hist, _ := m.engine.GetHistory(site.ID) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 18570aa..ddccd8b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -551,7 +551,12 @@ func (m Model) View() string { } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) hint := subtleStyle.Render("[y] Confirm [n] Cancel") - return lipgloss.NewStyle().Padding(2, 4).Render(msg + "\n\n" + hint) + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#F25D94")). + Padding(1, 3). + Render(msg + "\n\n" + hint) + return lipgloss.NewStyle().Padding(2, 4).Render(box) case stateFormSite, stateFormAlert, stateFormUser: if m.huhForm != nil { title := "" From 22c60221215ccb437ad5d6f7b849b92773aa0ec3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:28:37 -0400 Subject: [PATCH 24/35] feat(tui): DOWN-first sort, health pulse, and site filter - DOWN/SSL EXP monitors float to top of sites list - Pulse indicator turns red when any monitor is down, green when healthy - Press / to filter sites by name, Enter to lock filter, Esc to clear - Active filter shown in status bar --- internal/tui/tui.go | 107 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ddccd8b..813b456 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -82,6 +82,9 @@ type Model struct { alerts []models.AlertConfig users []models.User nodes []models.ProbeNode + + filterMode bool + filterText string } func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { @@ -247,6 +250,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen } + if m.filterMode { + switch msg.String() { + case "esc": + m.filterMode = false + m.filterText = "" + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + case "enter": + m.filterMode = false + case "backspace": + if len(m.filterText) > 0 { + m.filterText = m.filterText[:len(m.filterText)-1] + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + case "ctrl+c": + return m, tea.Quit + default: + if len(msg.String()) == 1 { + m.filterText += msg.String() + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + } + return m, nil + } + switch m.state { case stateDetail: switch msg.String() { @@ -260,6 +293,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m, tea.Quit + case "/": + if m.currentTab == 0 { + m.filterMode = true + return m, nil + } case "tab": m.switchTab(m.currentTab + 1) case "pgup", "pgdown": @@ -470,10 +508,10 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] - sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) + sort.Slice(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } - sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) + sort.Slice(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site for _, g := range groups { @@ -483,6 +521,16 @@ func (m *Model) refreshData() { } } ordered = append(ordered, ungrouped...) + if m.filterText != "" { + var filtered []models.Site + needle := strings.ToLower(m.filterText) + for _, s := range ordered { + if strings.Contains(strings.ToLower(s.Name), needle) { + filtered = append(filtered, s) + } + } + ordered = filtered + } m.sites = ordered if alerts, err := m.store.GetAllAlerts(); err == nil { m.alerts = alerts @@ -536,7 +584,19 @@ func (m Model) pulseIndicator() string { if brightness > 255 { brightness = 255 } - color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + hasDown := false + for _, s := range m.sites { + if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { + hasDown = true + break + } + } + var color string + if hasDown { + color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4) + } else { + color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + } return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) } @@ -674,16 +734,25 @@ func (m Model) viewDashboard() string { } statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) - var keys string - switch m.currentTab { - case 0: - keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" - case 4: - keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" - default: - keys = "[Tab]Switch [q]Quit" + var footer string + if m.filterMode { + cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│") + footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") + } else { + var keys string + switch m.currentTab { + case 0: + keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + case 4: + keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + default: + keys = "[Tab]Switch [q]Quit" + } + footer = "\n" + statusLine + " " + subtleStyle.Render(keys) + if m.filterText != "" && m.currentTab == 0 { + footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + } } - footer := "\n" + statusLine + " " + subtleStyle.Render(keys) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight) @@ -691,6 +760,20 @@ func (m Model) viewDashboard() string { return s.Render(header + "\n" + content + "\n" + footer) } +func siteOrder(s models.Site) int { + if s.Paused { + return 3 + } + switch s.Status { + case "DOWN", "SSL EXP": + return 0 + case "PENDING": + return 2 + default: + return 1 + } +} + func limitStr(text string, max int) string { if len(text) > max { return text[:max-3] + "..." From 426c38ea94ab910bcaff28eac44eac8ae1387168 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:33:20 -0400 Subject: [PATCH 25/35] fix(tui): use stable sort to prevent site list shuffling each tick --- internal/tui/tui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 813b456..1bc72b3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -508,10 +508,10 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] - sort.Slice(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) + sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } - sort.Slice(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) + sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site for _, g := range groups { From cc9dc24892947f8ec4b50a0c3b381c6c90bf4c07 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:36:49 -0400 Subject: [PATCH 26/35] fix(tui): sort children by ID before status to prevent map-order shuffling --- internal/tui/tui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1bc72b3..6f32161 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -508,9 +508,11 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] + sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } + sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site From f01533080f94d9d3ab95befa02419a07c5e95f59 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:43:34 -0400 Subject: [PATCH 27/35] feat(tui): split available width evenly between NAME and HISTORY columns --- internal/tui/tab_sites.go | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index faab54a..c41a26b 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -195,19 +195,31 @@ func fmtStatus(status string, paused bool) string { } } -func (m Model) nameWidth() int { - w := m.termWidth - 105 - if w < 13 { - w = 13 +func (m Model) dynamicWidths() (nameW, sparkW int) { + fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY + overhead := 30 // cell padding + borders + avail := m.termWidth - 6 - fixed - overhead + if avail < 30 { + avail = 30 } - if w > 40 { - w = 40 + nameW = avail / 2 + sparkW = avail - nameW - 4 // -4 for spark column padding + if nameW < 13 { + nameW = 13 } - return w + if nameW > 40 { + nameW = 40 + } + if sparkW < 10 { + sparkW = 10 + } + if sparkW > 40 { + sparkW = 40 + } + return } func (m Model) viewSitesTab() string { - const sparkWidth = 20 if len(m.sites) == 0 { welcome := lipgloss.NewStyle(). @@ -222,6 +234,7 @@ func (m Model) viewSitesTab() string { return "\n" + welcome } + nameW, sparkWidth := m.dynamicWidths() colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} var groupRows map[int]bool @@ -242,7 +255,7 @@ func (m Model) viewSitesTab() string { } rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, m.nameWidth()-2)), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, nameW-2)), "group", fmtStatus(site.Status, site.Paused), subtleStyle.Render("—"), @@ -260,9 +273,9 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, m.nameWidth()-2) + name = prefix + " " + limitStr(name, nameW-2) } else { - name = limitStr(name, m.nameWidth()) + name = limitStr(name, nameW) } hist, _ := m.engine.GetHistory(site.ID) From 1917540731d3eca2845c0ff9a445a08544e2de87 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:49:20 -0400 Subject: [PATCH 28/35] fix(tui): sparkline now spans full column width --- internal/tui/tab_sites.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index c41a26b..ebc4253 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -203,7 +203,7 @@ func (m Model) dynamicWidths() (nameW, sparkW int) { avail = 30 } nameW = avail / 2 - sparkW = avail - nameW - 4 // -4 for spark column padding + sparkW = avail - nameW - 2 // -2 for spark column padding if nameW < 13 { nameW = 13 } @@ -235,7 +235,7 @@ func (m Model) viewSitesTab() string { } nameW, sparkWidth := m.dynamicWidths() - colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} + colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} var groupRows map[int]bool return m.renderTable( From fc7b6f72e118910e104258360956eda6a6c5cc2d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:57:41 -0400 Subject: [PATCH 29/35] =?UTF-8?q?fix(tui):=20sparkline=20right-aligned=20?= =?UTF-8?q?=E2=80=94=20current=20time=20at=20right=20edge,=20dots=20fill?= =?UTF-8?q?=20left?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/tab_sites.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ebc4253..ffd50dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -61,6 +61,9 @@ func latencySparkline(latencies []time.Duration, width int) string { } var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } spread := maxL - minL for _, l := range samples { idx := 0 @@ -80,10 +83,6 @@ func latencySparkline(latencies []time.Duration, width int) string { sb.WriteString(dangerStyle.Render(ch)) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } return sb.String() } @@ -98,6 +97,9 @@ func heartbeatSparkline(statuses []bool, width int) string { } var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } for _, up := range samples { if up { sb.WriteString(specialStyle.Render("▁")) @@ -105,10 +107,6 @@ func heartbeatSparkline(statuses []bool, width int) string { sb.WriteString(dangerStyle.Render("█")) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } return sb.String() } From adf46a165424789b988c3a8eff9d416bc96b77fa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 14:01:25 -0400 Subject: [PATCH 30/35] fix(tui): increase history buffer to 60 so sparkline fills completely --- internal/monitor/history.go | 2 +- internal/tui/tab_sites.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/monitor/history.go b/internal/monitor/history.go index 1049e04..69a7f4d 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -2,7 +2,7 @@ package monitor import "time" -const maxHistoryLen = 30 +const maxHistoryLen = 60 type SiteHistory struct { Latencies []time.Duration diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ffd50dc..9417d9c 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -211,8 +211,8 @@ func (m Model) dynamicWidths() (nameW, sparkW int) { if sparkW < 10 { sparkW = 10 } - if sparkW > 40 { - sparkW = 40 + if sparkW > 60 { + sparkW = 60 } return } From 1eddb851b08324c1fb1c6b638656404372ee8ea7 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 14:35:38 -0400 Subject: [PATCH 31/35] feat(tui): add type icons to sites table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow-style icons per monitor type plus Nerd Font folder icons for groups (closed when collapsed, open when expanded): → http, ↓ push, ↔ ping, ⊡ port, ◆ dns, / group --- internal/tui/tab_sites.go | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 9417d9c..26816cb 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -15,6 +15,28 @@ import ( var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} +func typeIcon(siteType string, collapsed bool) string { + switch siteType { + case "http": + return "→" + case "push": + return "↓" + case "ping": + return "↔" + case "port": + return "⊡" + case "dns": + return "◆" + case "group": + if collapsed { + return "" + } + return "" + default: + return "·" + } +} + var siteGroupStyle = lipgloss.NewStyle(). Padding(0, 1). Bold(true). @@ -247,13 +269,10 @@ func (m Model) viewSitesTab() string { if site.Type == "group" { groupRows[i-start] = true - arrow := "▾" - if m.collapsed[site.ID] { - arrow = "▸" - } + icon := typeIcon("group", m.collapsed[site.ID]) rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, nameW-2)), + m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)), "group", fmtStatus(site.Status, site.Paused), subtleStyle.Render("—"), @@ -287,7 +306,7 @@ func (m Model) viewSitesTab() string { rows = append(rows, []string{ strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("site-%d", i), name), - site.Type, + typeIcon(site.Type, false) + " " + site.Type, fmtStatus(site.Status, site.Paused), fmtLatency(site.Latency), fmtUptime(hist.TotalChecks, hist.UpChecks), From 52c85b11b8e5e7de8529542199fa506c1d2b2729 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 14:58:34 -0400 Subject: [PATCH 32/35] fix(tui): compute uptime from windowed statuses, not running counters --- internal/tui/tab_sites.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 26816cb..6f7df4c 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -152,11 +152,17 @@ func fmtLatency(d time.Duration) string { return dangerStyle.Render(s) } -func fmtUptime(total, up int) string { - if total == 0 { +func fmtUptime(statuses []bool) string { + if len(statuses) == 0 { return subtleStyle.Render("—") } - pct := float64(up) / float64(total) * 100 + up := 0 + for _, s := range statuses { + if s { + up++ + } + } + pct := float64(up) / float64(len(statuses)) * 100 s := fmt.Sprintf("%.1f%%", pct) if pct >= 99 { return specialStyle.Render(s) @@ -309,7 +315,7 @@ func (m Model) viewSitesTab() string { typeIcon(site.Type, false) + " " + site.Type, fmtStatus(site.Status, site.Paused), fmtLatency(site.Latency), - fmtUptime(hist.TotalChecks, hist.UpChecks), + fmtUptime(hist.Statuses), spark, fmtSSL(site), fmtRetries(site), @@ -631,7 +637,7 @@ func (m Model) viewDetailPanel() string { row("Interval", fmt.Sprintf("%ds", site.Interval)) row("Timeout", fmt.Sprintf("%ds", site.Timeout)) row("Latency", fmtLatency(site.Latency)) - row("Uptime", fmtUptime(hist.TotalChecks, hist.UpChecks)) + row("Uptime", fmtUptime(hist.Statuses)) if site.Type == "http" { row("Method", site.Method) From 4d375cf8744a4c7846cc0bea2f51e3c202b91737 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 15:05:28 -0400 Subject: [PATCH 33/35] fix: seed status and latency from DB history on startup --- internal/monitor/monitor.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index ddabb74..8e55d48 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -193,6 +193,16 @@ func (e *Engine) Start(ctx context.Context) { if s.Type == "push" { s.LastCheck = time.Now() } + if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { + if h.Statuses[len(h.Statuses)-1] { + s.Status = "UP" + } else { + s.Status = "DOWN" + } + if len(h.Latencies) > 0 { + s.Latency = h.Latencies[len(h.Latencies)-1] + } + } e.liveState[s.ID] = s e.addToTokenIndex(s) e.mu.Unlock() From ed082e40805a56f84a0cb1bcde5037449c29455b Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 15:25:08 -0400 Subject: [PATCH 34/35] feat: persist logs to DB, load on startup --- cmd/goupkeep/main.go | 1 + internal/metrics/prometheus_test.go | 2 ++ internal/monitor/monitor.go | 14 ++++++++++++++ internal/store/postgres.go | 5 +++++ internal/store/sqlite.go | 5 +++++ internal/store/sqlstore.go | 28 ++++++++++++++++++++++++++++ internal/store/store.go | 4 ++++ 7 files changed, 59 insertions(+) diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 38dde72..adec38f 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -260,6 +260,7 @@ func runServe(args []string) { defer cancel() eng.InitHistory() + eng.InitLogs() eng.Start(ctx) server.Start(server.ServerConfig{ diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index cd86d26..1e2b72b 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -50,6 +50,8 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } +func (m *mockStore) SaveLog(string) error { return nil } +func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 8e55d48..07a0f41 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -68,6 +68,20 @@ func (e *Engine) AddLog(msg string) { if len(e.logStore) > 100 { e.logStore = e.logStore[:100] } + go func() { _ = e.db.SaveLog(entry) }() +} + +func (e *Engine) InitLogs() { + logs, err := e.db.LoadLogs(100) + if err != nil { + return + } + if len(logs) == 0 { + return + } + e.logMu.Lock() + defer e.logMu.Unlock() + e.logStore = logs } func (e *Engine) GetLogs() []string { diff --git a/internal/store/postgres.go b/internal/store/postgres.go index d6e6dbd..f8b5abc 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -51,6 +51,11 @@ func (d *PostgresDialect) CreateTablesSQL() []string { last_seen TIMESTAMP DEFAULT NOW(), version TEXT DEFAULT '' )`, + `CREATE TABLE IF NOT EXISTS logs ( + id SERIAL PRIMARY KEY, + message TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + )`, } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index be7ba1d..ea2cacc 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -51,6 +51,11 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, version TEXT DEFAULT '' )`, + `CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index e8b554a..294fd19 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -300,6 +300,34 @@ func (s *SQLStore) DeleteNode(id string) error { return err } +func (s *SQLStore) SaveLog(message string) error { + _, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message) + if err != nil { + return err + } + _, err = s.db.Exec(s.q(`DELETE FROM logs WHERE id NOT IN ( + SELECT id FROM logs ORDER BY created_at DESC LIMIT 200 + )`)) + return err +} + +func (s *SQLStore) LoadLogs(limit int) ([]string, error) { + rows, err := s.db.Query(s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) + if err != nil { + return nil, err + } + defer rows.Close() + var logs []string + for rows.Next() { + var msg string + if err := rows.Scan(&msg); err != nil { + return logs, err + } + logs = append(logs, msg) + } + return logs, rows.Err() +} + func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) { result := make(map[int][]models.CheckRecord) rows, err := s.db.Query(s.q(` diff --git a/internal/store/store.go b/internal/store/store.go index 1340326..fe96e37 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -45,6 +45,10 @@ type Store interface { UpdateNodeLastSeen(id string) error DeleteNode(id string) error + // Logs + SaveLog(message string) error + LoadLogs(limit int) ([]string, error) + // Backup & Restore ExportData() (models.Backup, error) ImportData(data models.Backup) error From 025b1b61d00bf0cc806e9f53a0c6ea60b06da98a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 15:45:09 -0400 Subject: [PATCH 35/35] fix(security): strip push tokens from /status/json response The public status JSON endpoint was serializing full Site structs including heartbeat tokens. An attacker could extract tokens and forge heartbeats to suppress DOWN alerts. Now tokens are stripped before encoding. Backup/export endpoint is unaffected. --- internal/server/server.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index 6f70df1..49f21a2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -358,8 +358,13 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { if cfg.EnableStatus { mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { + state := eng.GetLiveState() + for id, site := range state { + site.Token = "" + state[id] = site + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(eng.GetLiveState()) + json.NewEncoder(w).Encode(state) }) }