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..284497c 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"
@@ -445,7 +477,7 @@ func (e *Engine) checkGroup(site models.Site) {
if !child.Paused {
allPaused = false
}
- if child.Paused {
+ if child.Paused || e.isInMaintenance(child.ID) {
continue
}
if child.Status == "DOWN" || child.Status == "SSL EXP" {
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..1a2e9b6 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,11 @@ 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 wsm, ok := msg.(tea.WindowSizeMsg); ok {
+ m.termWidth = wsm.Width
+ m.termHeight = wsm.Height
+ }
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "ctrl+c" {
return m, tea.Quit
@@ -160,7 +172,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 +238,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 +345,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 +366,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 +383,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 +406,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 +429,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 +447,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 +485,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 +514,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 +527,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 +600,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 +611,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 +637,10 @@ func (m *Model) submitForm() {
if m.userFormData != nil {
m.submitUserForm()
}
+ case stateFormMaint:
+ if m.maintFormData != nil {
+ m.submitMaintForm()
+ }
}
}
@@ -593,7 +652,7 @@ func (m Model) pulseIndicator() string {
}
hasDown := false
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
break
}
@@ -614,6 +673,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 +685,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,7 +704,14 @@ func (m Model) View() string {
if m.editID > 0 {
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)
footer := subtleStyle.Render("\n[Esc] Cancel")
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 {
downCount := 0
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++
}
}
@@ -687,7 +755,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 +799,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 +835,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"