feat: add incident management and maintenance windows

Maintenance windows suppress alerts during planned downtime while checks
continue running. Incidents provide informational tracking. Supports
targeting all monitors, single monitor, or group (applies to children).

New Maint tab in TUI with create/end/delete. Status page, JSON API, and
Prometheus metrics all reflect maintenance state.
This commit is contained in:
2026-05-22 18:45:02 -04:00
parent 5de834465f
commit b146f34d19
12 changed files with 568 additions and 37 deletions
+9
View File
@@ -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() {
+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) 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{
+17 -4
View File
@@ -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"`
}
+38 -6
View File
@@ -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"
+21 -2
View File
@@ -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 = ['<span class="s-total">' + up + '/' + total + ' UP</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>');
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
}
@@ -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")
+13
View File
@@ -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))")
}
+13
View File
@@ -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) {}
+97 -1
View File
@@ -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()
+8
View File
@@ -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
+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
}
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)
+97 -20
View File
@@ -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"