diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index a85493f..4477db8 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -55,6 +55,15 @@ func Handler(eng *monitor.Engine) http.HandlerFunc { 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.") for _, s := range sites { if !s.HasSSL || s.CertExpiry.IsZero() { diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 1e2b72b..60167bc 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -52,6 +52,16 @@ func (m *mockStore) UpdateNodeLastSeen(string) error { return n 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 (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) { ms := &mockStore{ diff --git a/internal/models/models.go b/internal/models/models.go index 14a97c3..19179c6 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -67,8 +67,21 @@ type ProbeNode struct { Version string } -type Backup struct { - Sites []Site `json:"sites"` - Alerts []AlertConfig `json:"alerts"` - Users []User `json:"users"` +type MaintenanceWindow struct { + ID int + MonitorID int + 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"` } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 07a0f41..6281478 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -385,10 +385,16 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int newState.FailureCount = site.MaxRetries + 1 } + inMaint := e.isInMaintenance(site.ID) + 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" { - 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 } else if daysLeft > site.ExpiryThreshold { 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" } if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" { - 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) + if inMaint { + e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed โ€” maintenance)", 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" { - 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) { e.mu.RLock() status := "UP" diff --git a/internal/server/server.go b/internal/server/server.go index 49f21a2..e08188d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -35,6 +35,7 @@ var statusTpl = template.Must(template.New("status").Parse(` .PENDING { background: #e0af68; color: #1a1b26; } .SSL-EXP { background: #e0af68; color: #1a1b26; } .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 span { padding: 4px 12px; border-radius: 6px; } .summary .s-up { color: #9ece6a; } @@ -68,15 +69,17 @@ var statusTpl = template.Must(template.New("status").Parse(` } 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++) { if (sites[i].Paused) { paused++; continue; } + if (sites[i].Status === 'MAINT') { maint++; continue; } if (sites[i].Status === 'UP') up++; else if (sites[i].Status === 'DOWN') down++; } var el = document.getElementById('summary'); var parts = ['' + up + '/' + total + ' UP']; if (down > 0) parts.push('' + down + ' DOWN'); + if (maint > 0) parts.push('' + maint + ' MAINT'); if (paused > 0) parts.push('' + paused + ' PAUSED'); el.innerHTML = parts.join('ยท'); } @@ -110,7 +113,7 @@ var statusTpl = template.Must(template.New("status").Parse(` renderSummary(sites); for (var i = 0; i < sites.length; 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 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}) : 'โ€”'; @@ -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/json", func(w http.ResponseWriter, r *http.Request) { 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 { site.Token = "" + if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { + site.Status = "MAINT" + } state[id] = site } w.Header().Set("Content-Type", "application/json") diff --git a/internal/store/postgres.go b/internal/store/postgres.go index f8b5abc..f2a59e8 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -56,6 +56,17 @@ func (d *PostgresDialect) CreateTablesSQL() []string { message TEXT NOT NULL, 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 alerts 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) { tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))") tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))") + tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))") } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index ea2cacc..30fd07e 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -56,6 +56,17 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { message TEXT NOT NULL, 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 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) {} diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 294fd19..7db980c 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "go-upkeep/internal/models" + "time" ) type SQLStore struct { @@ -356,6 +357,90 @@ func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, erro 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) { sites, err := s.GetSites() if err != nil { @@ -369,7 +454,11 @@ func (s *SQLStore) ExportData() (models.Backup, error) { if err != nil { 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 { @@ -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) return tx.Commit() diff --git a/internal/store/store.go b/internal/store/store.go index fe96e37..3c59e0c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -49,6 +49,14 @@ type Store interface { SaveLog(message 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 ExportData() (models.Backup, error) ImportData(data models.Backup) error diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go new file mode 100644 index 0000000..0c4d135 --- /dev/null +++ b/internal/tui/tab_maint.go @@ -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 +} diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 6f7df4c..6a978bd 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -207,10 +207,13 @@ func fmtRetries(site models.Site) string { return s } -func fmtStatus(status string, paused bool) string { +func fmtStatus(status string, paused bool, inMaint bool) string { if paused { return warnStyle.Render("PAUSED") } + if inMaint { + return maintStyle.Render("MAINT") + } switch { case status == "DOWN" || status == "SSL EXP": return dangerStyle.Render(status) @@ -280,7 +283,7 @@ func (m Model) viewSitesTab() string { strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)), "group", - fmtStatus(site.Status, site.Paused), + fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("โ€”"), subtleStyle.Render("โ€”"), subtleStyle.Render(strings.Repeat("ยท", sparkWidth)), @@ -313,7 +316,7 @@ func (m Model) viewSitesTab() string { strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("site-%d", i), name), typeIcon(site.Type, false) + " " + site.Type, - fmtStatus(site.Status, site.Paused), + fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtLatency(site.Latency), fmtUptime(hist.Statuses), spark, @@ -623,7 +626,15 @@ func (m Model) viewDetailPanel() string { 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) if site.URL != "" { row("URL", site.URL) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2e93ea8..8f1cc44 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -42,6 +42,7 @@ const ( stateFormAlert stateFormUser stateConfirmDelete + stateFormMaint ) type Model struct { @@ -59,6 +60,7 @@ type Model struct { siteFormData *siteFormData alertFormData *alertFormData userFormData *userFormData + maintFormData *maintFormData logViewport viewport.Model isAdmin bool @@ -78,10 +80,11 @@ type Model struct { pulseVel float64 tickCount int - sites []models.Site - alerts []models.AlertConfig - users []models.User - nodes []models.ProbeNode + sites []models.Site + alerts []models.AlertConfig + users []models.User + nodes []models.ProbeNode + maintenanceWindows []models.MaintenanceWindow filterMode bool 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.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 { 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.state = stateDashboard - if m.deleteTab == 4 { + if m.deleteTab == 5 { m.state = stateUsers } case "n", "N", "esc": m.state = stateDashboard - if m.deleteTab == 4 { + if m.deleteTab == 5 { m.state = stateUsers } case "ctrl+c": @@ -152,7 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // 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 keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "ctrl+c" { return m, tea.Quit @@ -160,7 +168,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 == 4 { + if m.currentTab == 5 { m.state = stateUsers } return m, nil @@ -226,6 +234,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.currentTab == 3 { listLen = len(m.nodes) } else if m.currentTab == 4 { + listLen = len(m.maintenanceWindows) + } else if m.currentTab == 5 { listLen = len(m.users) } if msg.Button == tea.MouseButtonWheelUp { @@ -331,6 +341,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { max = len(m.nodes) - 1 } if m.currentTab == 4 { + max = len(m.maintenanceWindows) - 1 + } + if m.currentTab == 5 { max = len(m.users) - 1 } if m.cursor < max { @@ -349,7 +362,10 @@ 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 == 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 return m, m.initUserHuhForm() } @@ -363,7 +379,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 == 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.state = stateFormUser return m, m.initUserHuhForm() @@ -386,6 +402,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentTab == 0 && len(m.sites) > 0 { 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": if m.currentTab == 0 && len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID @@ -397,10 +425,15 @@ 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 == 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.deleteName = m.users[m.cursor].Username - m.deleteTab = 4 + m.deleteTab = 5 m.state = stateConfirmDelete } } @@ -410,9 +443,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := 4 + tabCount := 5 if m.isAdmin { - tabCount = 5 + tabCount = 6 } for i := 0; i < tabCount; i++ { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { @@ -448,6 +481,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } 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 if end > len(m.users) { end = len(m.users) @@ -464,9 +510,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m *Model) switchTab(idx int) { - maxTabs := 3 + maxTabs := 4 if m.isAdmin { - maxTabs = 4 + maxTabs = 5 } if idx > maxTabs { idx = 0 @@ -477,7 +523,7 @@ func (m *Model) switchTab(idx int) { switch idx { case 2: m.state = stateLogs - case 4: + case 5: m.state = stateUsers default: m.state = stateDashboard @@ -550,6 +596,9 @@ func (m *Model) refreshData() { if nodes, err := m.store.GetAllNodes(); err == nil { m.nodes = nodes } + if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil { + m.maintenanceWindows = windows + } m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) listLen := len(m.sites) @@ -558,6 +607,8 @@ func (m *Model) refreshData() { } else if m.currentTab == 3 { listLen = len(m.nodes) } else if m.currentTab == 4 { + listLen = len(m.maintenanceWindows) + } else if m.currentTab == 5 { listLen = len(m.users) } if listLen > 0 && m.cursor >= listLen { @@ -582,6 +633,10 @@ func (m *Model) submitForm() { if m.userFormData != nil { m.submitUserForm() } + case stateFormMaint: + if m.maintFormData != nil { + m.submitMaintForm() + } } } @@ -614,6 +669,8 @@ func (m Model) View() string { if m.deleteTab == 1 { kind = "alert" } else if m.deleteTab == 4 { + kind = "maintenance window" + } else if m.deleteTab == 5 { kind = "user" } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) @@ -624,7 +681,7 @@ func (m Model) View() string { Padding(1, 3). Render(msg + "\n\n" + hint) return lipgloss.NewStyle().Padding(2, 4).Render(box) - case stateFormSite, stateFormAlert, stateFormUser: + case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint: if m.huhForm != nil { title := "" switch m.state { @@ -643,6 +700,8 @@ func (m Model) View() string { if m.editID > 0 { title = fmt.Sprintf("Edit User #%d", m.editID) } + case stateFormMaint: + title = "New Maintenance Window" } header := titleStyle.Render(title) footer := subtleStyle.Render("\n[Esc] Cancel") @@ -687,7 +746,21 @@ func (m Model) viewDashboard() string { 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 { tabs = append(tabs, "Users") } @@ -717,6 +790,8 @@ func (m Model) viewDashboard() string { case 3: content = m.viewNodesTab() case 4: + content = m.viewMaintTab() + case 5: if m.isAdmin { content = m.viewUsersTab() } @@ -751,6 +826,8 @@ func (m Model) viewDashboard() string { case 0: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" case 4: + keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit" + case 5: keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" default: keys = "[Tab]Switch [q]Quit"