feat: incident management and maintenance windows #17

Merged
lerko merged 4 commits from feat/incident-management into main 2026-05-22 23:34:16 +00:00
12 changed files with 580 additions and 40 deletions
+9
View File
@@ -55,6 +55,15 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
}
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{
+13
View File
@@ -67,8 +67,21 @@ type ProbeNode struct {
Version string
}
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"`
}
+33 -1
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" {
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" {
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)
}
}
if isBroken(site.Status) && newState.Status == "UP" {
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" {
+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)
+104 -18
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
@@ -82,6 +84,7 @@ type Model struct {
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"