Merge pull request 'feat: incident management and maintenance windows' (#17) from feat/incident-management into main

Reviewed-on: lerko/uptime#17
This commit was merged in pull request #17.
This commit is contained in:
2026-05-22 23:34:16 +00:00
12 changed files with 580 additions and 40 deletions
+9
View File
@@ -55,6 +55,15 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val)) writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
for _, s := range sites {
val := 0
if eng.GetDisplayStatus(s) == "MAINT" {
val = 1
}
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val))
}
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.") writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
for _, s := range sites { for _, s := range sites {
if !s.HasSSL || s.CertExpiry.IsZero() { if !s.HasSSL || s.CertExpiry.IsZero() {
+10
View File
@@ -52,6 +52,16 @@ func (m *mockStore) UpdateNodeLastSeen(string) error { return n
func (m *mockStore) DeleteNode(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil } func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func TestMetricsHandler(t *testing.T) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
+17 -4
View File
@@ -67,8 +67,21 @@ type ProbeNode struct {
Version string Version string
} }
type Backup struct { type MaintenanceWindow struct {
Sites []Site `json:"sites"` ID int
Alerts []AlertConfig `json:"alerts"` MonitorID int
Users []User `json:"users"` Title string
Description string
Type string // "maintenance" or "incident"
StartTime time.Time
EndTime time.Time // zero = ongoing
CreatedBy string
CreatedAt time.Time
}
type Backup struct {
Sites []Site `json:"sites"`
Alerts []AlertConfig `json:"alerts"`
Users []User `json:"users"`
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
} }
+39 -7
View File
@@ -385,10 +385,16 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
newState.FailureCount = site.MaxRetries + 1 newState.FailureCount = site.MaxRetries + 1
} }
inMaint := e.isInMaintenance(site.ID)
if site.Type == "http" && site.CheckSSL && site.HasSSL { if site.Type == "http" && site.CheckSSL && site.HasSSL {
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24) daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" { if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft)) if !inMaint {
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
} else {
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", site.Name))
}
newState.SentSSLWarning = true newState.SentSSLWarning = true
} else if daysLeft > site.ExpiryThreshold { } else if daysLeft > site.ExpiryThreshold {
newState.SentSSLWarning = false newState.SentSSLWarning = false
@@ -405,14 +411,22 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" { if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus) if inMaint {
if site.Type == "push" { e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name) } else {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
if site.Type == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
}
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
} }
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
} }
if isBroken(site.Status) && newState.Status == "UP" { if isBroken(site.Status) && newState.Status == "UP" {
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) if !inMaint {
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
}
} }
} }
@@ -432,6 +446,24 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
} }
} }
func (e *Engine) isInMaintenance(monitorID int) bool {
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
if err != nil {
return false
}
return inMaint
}
func (e *Engine) GetDisplayStatus(site models.Site) string {
if site.Paused {
return "PAUSED"
}
if e.isInMaintenance(site.ID) {
return "MAINT"
}
return site.Status
}
func (e *Engine) checkGroup(site models.Site) { func (e *Engine) checkGroup(site models.Site) {
e.mu.RLock() e.mu.RLock()
status := "UP" status := "UP"
@@ -445,7 +477,7 @@ func (e *Engine) checkGroup(site models.Site) {
if !child.Paused { if !child.Paused {
allPaused = false allPaused = false
} }
if child.Paused { if child.Paused || e.isInMaintenance(child.ID) {
continue continue
} }
if child.Status == "DOWN" || child.Status == "SSL EXP" { if child.Status == "DOWN" || child.Status == "SSL EXP" {
+21 -2
View File
@@ -35,6 +35,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
.PENDING { background: #e0af68; color: #1a1b26; } .PENDING { background: #e0af68; color: #1a1b26; }
.SSL-EXP { background: #e0af68; color: #1a1b26; } .SSL-EXP { background: #e0af68; color: #1a1b26; }
.PAUSED { background: #565f89; color: #c0caf5; } .PAUSED { background: #565f89; color: #c0caf5; }
.MAINT { background: #bb9af7; color: #1a1b26; }
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; } .summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
.summary span { padding: 4px 12px; border-radius: 6px; } .summary span { padding: 4px 12px; border-radius: 6px; }
.summary .s-up { color: #9ece6a; } .summary .s-up { color: #9ece6a; }
@@ -68,15 +69,17 @@ var statusTpl = template.Must(template.New("status").Parse(`
} }
function renderSummary(sites) { function renderSummary(sites) {
var up = 0, down = 0, paused = 0, total = sites.length; var up = 0, down = 0, paused = 0, maint = 0, total = sites.length;
for (var i = 0; i < sites.length; i++) { for (var i = 0; i < sites.length; i++) {
if (sites[i].Paused) { paused++; continue; } if (sites[i].Paused) { paused++; continue; }
if (sites[i].Status === 'MAINT') { maint++; continue; }
if (sites[i].Status === 'UP') up++; if (sites[i].Status === 'UP') up++;
else if (sites[i].Status === 'DOWN') down++; else if (sites[i].Status === 'DOWN') down++;
} }
var el = document.getElementById('summary'); var el = document.getElementById('summary');
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>']; var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>'); if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
if (maint > 0) parts.push('<span style="color:#bb9af7">' + maint + ' MAINT</span>');
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>'); if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
el.innerHTML = parts.join('<span style="color:#383838">·</span>'); el.innerHTML = parts.join('<span style="color:#383838">·</span>');
} }
@@ -110,7 +113,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
renderSummary(sites); renderSummary(sites);
for (var i = 0; i < sites.length; i++) { for (var i = 0; i < sites.length; i++) {
var s = sites[i]; var s = sites[i];
var st = s.Paused ? 'PAUSED' : s.Status; var st = s.Status === 'MAINT' ? 'MAINT' : s.Paused ? 'PAUSED' : s.Status;
var cls = cssClass(st); var cls = cssClass(st);
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor'); var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—'; var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
@@ -359,8 +362,24 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) 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) { mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
state := eng.GetLiveState() state := eng.GetLiveState()
activeWindows, _ := s.GetActiveMaintenanceWindows()
maintSet := make(map[int]bool)
allInMaint := false
for _, mw := range activeWindows {
if mw.Type != "maintenance" {
continue
}
if mw.MonitorID == 0 {
allInMaint = true
} else {
maintSet[mw.MonitorID] = true
}
}
for id, site := range state { for id, site := range state {
site.Token = "" site.Token = ""
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
site.Status = "MAINT"
}
state[id] = site state[id] = site
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+13
View File
@@ -56,6 +56,17 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
message TEXT NOT NULL, message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS maintenance_windows (
id SERIAL PRIMARY KEY,
monitor_id INTEGER DEFAULT 0,
title TEXT NOT NULL,
description TEXT DEFAULT '',
type TEXT DEFAULT 'maintenance',
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
created_by TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW()
)`,
} }
} }
@@ -87,10 +98,12 @@ func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE")
} }
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") 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('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))")
tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))") tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))")
tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))")
} }
+13
View File
@@ -56,6 +56,17 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
message TEXT NOT NULL, message TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS maintenance_windows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER DEFAULT 0,
title TEXT NOT NULL,
description TEXT DEFAULT '',
type TEXT DEFAULT 'maintenance',
start_time DATETIME NOT NULL,
end_time DATETIME,
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
} }
} }
@@ -96,6 +107,8 @@ func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
tx.Exec("DELETE FROM users") tx.Exec("DELETE FROM users")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'")
tx.Exec("DELETE FROM maintenance_windows")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'")
} }
func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {} func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
+97 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"time"
) )
type SQLStore struct { type SQLStore struct {
@@ -356,6 +357,90 @@ func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, erro
return result, rows.Err() return result, rows.Err()
} }
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
var mw models.MaintenanceWindow
var endTime sql.NullTime
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
return mw, err
}
if endTime.Valid {
mw.EndTime = endTime.Time
}
return mw, nil
}
func (s *SQLStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error) {
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) AddMaintenanceWindow(mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() {
mw.StartTime = time.Now()
}
_, err := s.db.Exec(s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err
}
func (s *SQLStore) EndMaintenanceWindow(id int) error {
_, err := s.db.Exec(s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteMaintenanceWindow(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
return nil
}
func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
var count int
err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
AND (monitor_id = 0 OR monitor_id = ?
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
monitorID, monitorID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *SQLStore) ExportData() (models.Backup, error) { func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites() sites, err := s.GetSites()
if err != nil { if err != nil {
@@ -369,7 +454,11 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
return models.Backup{Sites: sites, Alerts: alerts, Users: users}, nil windows, err := s.GetAllMaintenanceWindows(1000)
if err != nil {
return models.Backup{}, err
}
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
} }
func (s *SQLStore) ImportData(data models.Backup) error { func (s *SQLStore) ImportData(data models.Backup) error {
@@ -403,6 +492,13 @@ func (s *SQLStore) ImportData(data models.Backup) error {
} }
} }
for _, mw := range data.MaintenanceWindows {
if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil {
return err
}
}
s.dialect.ImportResetSequences(tx) s.dialect.ImportResetSequences(tx)
return tx.Commit() return tx.Commit()
+8
View File
@@ -49,6 +49,14 @@ type Store interface {
SaveLog(message string) error SaveLog(message string) error
LoadLogs(limit int) ([]string, error) LoadLogs(limit int) ([]string, error)
// Maintenance Windows
GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
AddMaintenanceWindow(mw models.MaintenanceWindow) error
EndMaintenanceWindow(id int) error
DeleteMaintenanceWindow(id int) error
IsMonitorInMaintenance(monitorID int) (bool, error)
// Backup & Restore // Backup & Restore
ExportData() (models.Backup, error) ExportData() (models.Backup, error)
ImportData(data models.Backup) error ImportData(data models.Backup) error
+230
View File
@@ -0,0 +1,230 @@
package tui
import (
"fmt"
"go-upkeep/internal/models"
"strconv"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))
type maintFormData struct {
Title string
Description string
Type string
MonitorID string
Duration string
CustomHours string
}
func fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now()
if mw.StartTime.After(now) {
return warnStyle.Render("SCHEDULED")
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
return subtleStyle.Render("ENDED")
}
return specialStyle.Render("ACTIVE")
}
func fmtMaintType(t string) string {
if t == "incident" {
return dangerStyle.Render("incident")
}
return maintStyle.Render("maintenance")
}
func fmtMaintMonitor(monitorID int, sites []models.Site) string {
if monitorID == 0 {
return "All"
}
for _, s := range sites {
if s.ID == monitorID {
return limitStr(s.Name, 18)
}
}
return fmt.Sprintf("#%d", monitorID)
}
func fmtMaintTime(t time.Time) string {
if t.IsZero() {
return subtleStyle.Render("—")
}
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
return t.Format("15:04 Jan 02")
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" {
continue
}
now := time.Now()
if mw.StartTime.After(now) {
continue
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
return true
}
for _, s := range m.sites {
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
return true
}
}
}
return false
}
func (m Model) viewMaintTab() string {
if len(m.maintenanceWindows) == 0 {
return "\n No maintenance windows or incidents. Press [n] to create one."
}
return m.renderTable(
[]string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"},
len(m.maintenanceWindows),
func(start, end int) [][]string {
var rows [][]string
allSites := m.engine.GetAllSites()
for i := start; i < end; i++ {
mw := m.maintenanceWindows[i]
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, 24)),
fmtMaintType(mw.Type),
fmtMaintMonitor(mw.MonitorID, allSites),
fmtMaintStatus(mw),
fmtMaintTime(mw.StartTime),
fmtMaintTime(mw.EndTime),
})
}
return rows
},
[]int{6, 0, 14, 20, 12, 16, 16},
nil,
)
}
func (m *Model) initMaintHuhForm() tea.Cmd {
m.maintFormData = &maintFormData{
Type: "maintenance",
MonitorID: "0",
Duration: "1h",
CustomHours: "12",
}
monitorOpts := []huh.Option[string]{huh.NewOption("All Monitors", "0")}
allSites := m.engine.GetAllSites()
for _, s := range allSites {
label := s.Name
if s.Type == "group" {
label = s.Name + " (group)"
}
monitorOpts = append(monitorOpts, huh.NewOption(label, strconv.Itoa(s.ID)))
}
m.huhForm = huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Title").
Placeholder("DB Migration").
Value(&m.maintFormData.Title).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("title is required")
}
return nil
}),
huh.NewSelect[string]().Title("Type").
Options(
huh.NewOption("Maintenance (suppress alerts)", "maintenance"),
huh.NewOption("Incident (informational)", "incident"),
).Value(&m.maintFormData.Type),
huh.NewSelect[string]().Title("Affected Monitors").
Options(monitorOpts...).
Value(&m.maintFormData.MonitorID),
huh.NewInput().Title("Description").
Placeholder("Optional notes").
Value(&m.maintFormData.Description),
).Title("Maintenance Window"),
huh.NewGroup(
huh.NewSelect[string]().Title("Duration").
Options(
huh.NewOption("1 hour", "1h"),
huh.NewOption("2 hours", "2h"),
huh.NewOption("4 hours", "4h"),
huh.NewOption("8 hours", "8h"),
huh.NewOption("Indefinite (end manually)", "indefinite"),
huh.NewOption("Custom", "custom"),
).Value(&m.maintFormData.Duration),
huh.NewInput().Title("Custom Duration (hours)").
Placeholder("12").
Value(&m.maintFormData.CustomHours).
Validate(func(s string) error {
if m.maintFormData.Duration != "custom" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 {
return fmt.Errorf("must be at least 1 hour")
}
return nil
}),
).Title("Duration").WithHideFunc(func() bool {
return m.maintFormData.Type == "incident"
}),
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
}
func (m *Model) submitMaintForm() {
d := m.maintFormData
monitorID, _ := strconv.Atoi(d.MonitorID)
mw := models.MaintenanceWindow{
MonitorID: monitorID,
Title: d.Title,
Description: d.Description,
Type: d.Type,
StartTime: time.Now(),
}
if d.Type == "maintenance" {
switch d.Duration {
case "1h":
mw.EndTime = mw.StartTime.Add(1 * time.Hour)
case "2h":
mw.EndTime = mw.StartTime.Add(2 * time.Hour)
case "4h":
mw.EndTime = mw.StartTime.Add(4 * time.Hour)
case "8h":
mw.EndTime = mw.StartTime.Add(8 * time.Hour)
case "custom":
hours, _ := strconv.Atoi(d.CustomHours)
if hours < 1 {
hours = 1
}
mw.EndTime = mw.StartTime.Add(time.Duration(hours) * time.Hour)
}
}
if err := m.store.AddMaintenanceWindow(mw); err != nil {
m.engine.AddLog("Add maintenance window failed: " + err.Error())
}
m.state = stateDashboard
}
+15 -4
View File
@@ -207,10 +207,13 @@ func fmtRetries(site models.Site) string {
return s return s
} }
func fmtStatus(status string, paused bool) string { func fmtStatus(status string, paused bool, inMaint bool) string {
if paused { if paused {
return warnStyle.Render("PAUSED") return warnStyle.Render("PAUSED")
} }
if inMaint {
return maintStyle.Render("MAINT")
}
switch { switch {
case status == "DOWN" || status == "SSL EXP": case status == "DOWN" || status == "SSL EXP":
return dangerStyle.Render(status) return dangerStyle.Render(status)
@@ -280,7 +283,7 @@ func (m Model) viewSitesTab() string {
strconv.Itoa(i + 1), strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)), m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
"group", "group",
fmtStatus(site.Status, site.Paused), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
subtleStyle.Render("—"), subtleStyle.Render("—"),
subtleStyle.Render("—"), subtleStyle.Render("—"),
subtleStyle.Render(strings.Repeat("·", sparkWidth)), subtleStyle.Render(strings.Repeat("·", sparkWidth)),
@@ -313,7 +316,7 @@ func (m Model) viewSitesTab() string {
strconv.Itoa(i + 1), strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), name), m.zones.Mark(fmt.Sprintf("site-%d", i), name),
typeIcon(site.Type, false) + " " + site.Type, typeIcon(site.Type, false) + " " + site.Type,
fmtStatus(site.Status, site.Paused), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
fmtLatency(site.Latency), fmtLatency(site.Latency),
fmtUptime(hist.Statuses), fmtUptime(hist.Statuses),
spark, spark,
@@ -623,7 +626,15 @@ func (m Model) viewDetailPanel() string {
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
} }
row("Status", fmtStatus(site.Status, site.Paused)) row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
row("Maintenance", maintStyle.Render(mw.Title))
break
}
}
}
row("Type", site.Type) row("Type", site.Type)
if site.URL != "" { if site.URL != "" {
row("URL", site.URL) row("URL", site.URL)
+108 -22
View File
@@ -42,6 +42,7 @@ const (
stateFormAlert stateFormAlert
stateFormUser stateFormUser
stateConfirmDelete stateConfirmDelete
stateFormMaint
) )
type Model struct { type Model struct {
@@ -59,6 +60,7 @@ type Model struct {
siteFormData *siteFormData siteFormData *siteFormData
alertFormData *alertFormData alertFormData *alertFormData
userFormData *userFormData userFormData *userFormData
maintFormData *maintFormData
logViewport viewport.Model logViewport viewport.Model
isAdmin bool isAdmin bool
@@ -78,10 +80,11 @@ type Model struct {
pulseVel float64 pulseVel float64
tickCount int tickCount int
sites []models.Site sites []models.Site
alerts []models.AlertConfig alerts []models.AlertConfig
users []models.User users []models.User
nodes []models.ProbeNode nodes []models.ProbeNode
maintenanceWindows []models.MaintenanceWindow
filterMode bool filterMode bool
filterText string filterText string
@@ -128,7 +131,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.engine.AddLog("Delete alert failed: " + err.Error()) m.engine.AddLog("Delete alert failed: " + err.Error())
} }
m.adjustCursor(len(m.alerts) - 1) m.adjustCursor(len(m.alerts) - 1)
case 3: case 4:
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
}
m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5:
if err := m.store.DeleteUser(m.deleteID); err != nil { if err := m.store.DeleteUser(m.deleteID); err != nil {
m.engine.AddLog("Delete user failed: " + err.Error()) m.engine.AddLog("Delete user failed: " + err.Error())
} }
@@ -136,12 +144,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.refreshData() m.refreshData()
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 4 { if m.deleteTab == 5 {
m.state = stateUsers m.state = stateUsers
} }
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 4 { if m.deleteTab == 5 {
m.state = stateUsers m.state = stateUsers
} }
case "ctrl+c": case "ctrl+c":
@@ -152,7 +160,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// Form state: forward ALL messages to huh (keys, timers, resize, etc.) // Form state: forward ALL messages to huh (keys, timers, resize, etc.)
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser { if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
m.termWidth = wsm.Width
m.termHeight = wsm.Height
}
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "ctrl+c" { if keyMsg.String() == "ctrl+c" {
return m, tea.Quit return m, tea.Quit
@@ -160,7 +172,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg.String() == "esc" { if keyMsg.String() == "esc" {
m.huhForm = nil m.huhForm = nil
m.state = stateDashboard m.state = stateDashboard
if m.currentTab == 4 { if m.currentTab == 5 {
m.state = stateUsers m.state = stateUsers
} }
return m, nil return m, nil
@@ -226,6 +238,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if m.currentTab == 3 { } else if m.currentTab == 3 {
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { } else if m.currentTab == 4 {
listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 {
listLen = len(m.users) listLen = len(m.users)
} }
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
@@ -331,6 +345,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
max = len(m.nodes) - 1 max = len(m.nodes) - 1
} }
if m.currentTab == 4 { if m.currentTab == 4 {
max = len(m.maintenanceWindows) - 1
}
if m.currentTab == 5 {
max = len(m.users) - 1 max = len(m.users) - 1
} }
if m.cursor < max { if m.cursor < max {
@@ -349,7 +366,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if m.currentTab == 1 { } else if m.currentTab == 1 {
m.state = stateFormAlert m.state = stateFormAlert
return m, m.initAlertHuhForm() return m, m.initAlertHuhForm()
} else if m.currentTab == 4 && m.isAdmin { } else if m.currentTab == 4 {
m.state = stateFormMaint
return m, m.initMaintHuhForm()
} else if m.currentTab == 5 && m.isAdmin {
m.state = stateFormUser m.state = stateFormUser
return m, m.initUserHuhForm() return m, m.initUserHuhForm()
} }
@@ -363,7 +383,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editID = m.alerts[m.cursor].ID m.editID = m.alerts[m.cursor].ID
m.state = stateFormAlert m.state = stateFormAlert
return m, m.initAlertHuhForm() return m, m.initAlertHuhForm()
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 { } else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
m.editID = m.users[m.cursor].ID m.editID = m.users[m.cursor].ID
m.state = stateFormUser m.state = stateFormUser
return m, m.initUserHuhForm() return m, m.initUserHuhForm()
@@ -386,6 +406,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
m.state = stateDetail m.state = stateDetail
} }
case "x":
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
mw := m.maintenanceWindows[m.cursor]
now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive {
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
m.engine.AddLog("End maintenance failed: " + err.Error())
}
m.refreshData()
}
}
case "d", "backspace": case "d", "backspace":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID m.deleteID = m.sites[m.cursor].ID
@@ -397,10 +429,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.deleteName = m.alerts[m.cursor].Name m.deleteName = m.alerts[m.cursor].Name
m.deleteTab = 1 m.deleteTab = 1
m.state = stateConfirmDelete m.state = stateConfirmDelete
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 { } else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
m.deleteID = m.maintenanceWindows[m.cursor].ID
m.deleteName = m.maintenanceWindows[m.cursor].Title
m.deleteTab = 4
m.state = stateConfirmDelete
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
m.deleteID = m.users[m.cursor].ID m.deleteID = m.users[m.cursor].ID
m.deleteName = m.users[m.cursor].Username m.deleteName = m.users[m.cursor].Username
m.deleteTab = 4 m.deleteTab = 5
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
} }
@@ -410,9 +447,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
tabCount := 4 tabCount := 5
if m.isAdmin { if m.isAdmin {
tabCount = 5 tabCount = 6
} }
for i := 0; i < tabCount; i++ { for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
@@ -448,6 +485,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
if m.currentTab == 4 { if m.currentTab == 4 {
end := m.tableOffset + m.maxTableRows
if end > len(m.maintenanceWindows) {
end = len(m.maintenanceWindows)
}
for i := m.tableOffset; i < end; i++ {
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
m.cursor = i
return m, nil
}
}
}
if m.currentTab == 5 {
end := m.tableOffset + m.maxTableRows end := m.tableOffset + m.maxTableRows
if end > len(m.users) { if end > len(m.users) {
end = len(m.users) end = len(m.users)
@@ -464,9 +514,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
func (m *Model) switchTab(idx int) { func (m *Model) switchTab(idx int) {
maxTabs := 3 maxTabs := 4
if m.isAdmin { if m.isAdmin {
maxTabs = 4 maxTabs = 5
} }
if idx > maxTabs { if idx > maxTabs {
idx = 0 idx = 0
@@ -477,7 +527,7 @@ func (m *Model) switchTab(idx int) {
switch idx { switch idx {
case 2: case 2:
m.state = stateLogs m.state = stateLogs
case 4: case 5:
m.state = stateUsers m.state = stateUsers
default: default:
m.state = stateDashboard m.state = stateDashboard
@@ -550,6 +600,9 @@ func (m *Model) refreshData() {
if nodes, err := m.store.GetAllNodes(); err == nil { if nodes, err := m.store.GetAllNodes(); err == nil {
m.nodes = nodes m.nodes = nodes
} }
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
m.maintenanceWindows = windows
}
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
listLen := len(m.sites) listLen := len(m.sites)
@@ -558,6 +611,8 @@ func (m *Model) refreshData() {
} else if m.currentTab == 3 { } else if m.currentTab == 3 {
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { } else if m.currentTab == 4 {
listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 {
listLen = len(m.users) listLen = len(m.users)
} }
if listLen > 0 && m.cursor >= listLen { if listLen > 0 && m.cursor >= listLen {
@@ -582,6 +637,10 @@ func (m *Model) submitForm() {
if m.userFormData != nil { if m.userFormData != nil {
m.submitUserForm() m.submitUserForm()
} }
case stateFormMaint:
if m.maintFormData != nil {
m.submitMaintForm()
}
} }
} }
@@ -593,7 +652,7 @@ func (m Model) pulseIndicator() string {
} }
hasDown := false hasDown := false
for _, s := range m.sites { for _, s := range m.sites {
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
hasDown = true hasDown = true
break break
} }
@@ -614,6 +673,8 @@ func (m Model) View() string {
if m.deleteTab == 1 { if m.deleteTab == 1 {
kind = "alert" kind = "alert"
} else if m.deleteTab == 4 { } else if m.deleteTab == 4 {
kind = "maintenance window"
} else if m.deleteTab == 5 {
kind = "user" kind = "user"
} }
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
@@ -624,7 +685,7 @@ func (m Model) View() string {
Padding(1, 3). Padding(1, 3).
Render(msg + "\n\n" + hint) Render(msg + "\n\n" + hint)
return lipgloss.NewStyle().Padding(2, 4).Render(box) return lipgloss.NewStyle().Padding(2, 4).Render(box)
case stateFormSite, stateFormAlert, stateFormUser: case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
if m.huhForm != nil { if m.huhForm != nil {
title := "" title := ""
switch m.state { switch m.state {
@@ -643,7 +704,14 @@ func (m Model) View() string {
if m.editID > 0 { if m.editID > 0 {
title = fmt.Sprintf("Edit User #%d", m.editID) title = fmt.Sprintf("Edit User #%d", m.editID)
} }
case stateFormMaint:
title = "New Maintenance Window"
} }
formHeight := m.termHeight - 7
if formHeight < 5 {
formHeight = 5
}
m.huhForm.WithHeight(formHeight)
header := titleStyle.Render(title) header := titleStyle.Render(title)
footer := subtleStyle.Render("\n[Esc] Cancel") footer := subtleStyle.Render("\n[Esc] Cancel")
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
@@ -659,7 +727,7 @@ func (m Model) View() string {
func (m Model) viewDashboard() string { func (m Model) viewDashboard() string {
downCount := 0 downCount := 0
for _, s := range m.sites { for _, s := range m.sites {
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
downCount++ downCount++
} }
} }
@@ -687,7 +755,21 @@ func (m Model) viewDashboard() string {
nodesLabel = "Nodes" nodesLabel = "Nodes"
} }
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} activeMaint := 0
for _, mw := range m.maintenanceWindows {
now := time.Now()
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
activeMaint++
}
}
var maintLabel string
if activeMaint > 0 {
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
} else {
maintLabel = "Maint"
}
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
if m.isAdmin { if m.isAdmin {
tabs = append(tabs, "Users") tabs = append(tabs, "Users")
} }
@@ -717,6 +799,8 @@ func (m Model) viewDashboard() string {
case 3: case 3:
content = m.viewNodesTab() content = m.viewNodesTab()
case 4: case 4:
content = m.viewMaintTab()
case 5:
if m.isAdmin { if m.isAdmin {
content = m.viewUsersTab() content = m.viewUsersTab()
} }
@@ -751,6 +835,8 @@ func (m Model) viewDashboard() string {
case 0: case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
case 4: case 4:
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit"
case 5:
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
default: default:
keys = "[Tab]Switch [q]Quit" keys = "[Tab]Switch [q]Quit"