feat: incident management and maintenance windows #17
@@ -55,6 +55,15 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
|
|||||||
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
|
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
|
||||||
|
for _, s := range sites {
|
||||||
|
val := 0
|
||||||
|
if eng.GetDisplayStatus(s) == "MAINT" {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val))
|
||||||
|
}
|
||||||
|
|
||||||
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if !s.HasSSL || s.CertExpiry.IsZero() {
|
if !s.HasSSL || s.CertExpiry.IsZero() {
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ func (m *mockStore) UpdateNodeLastSeen(string) error { return n
|
|||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
|
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||||
|
|
||||||
func TestMetricsHandler(t *testing.T) {
|
func TestMetricsHandler(t *testing.T) {
|
||||||
ms := &mockStore{
|
ms := &mockStore{
|
||||||
|
|||||||
@@ -67,8 +67,21 @@ type ProbeNode struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Backup struct {
|
type MaintenanceWindow struct {
|
||||||
Sites []Site `json:"sites"`
|
ID int
|
||||||
Alerts []AlertConfig `json:"alerts"`
|
MonitorID int
|
||||||
Users []User `json:"users"`
|
Title string
|
||||||
|
Description string
|
||||||
|
Type string // "maintenance" or "incident"
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time // zero = ongoing
|
||||||
|
CreatedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Backup struct {
|
||||||
|
Sites []Site `json:"sites"`
|
||||||
|
Alerts []AlertConfig `json:"alerts"`
|
||||||
|
Users []User `json:"users"`
|
||||||
|
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,10 +385,16 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
newState.FailureCount = site.MaxRetries + 1
|
newState.FailureCount = site.MaxRetries + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inMaint := e.isInMaintenance(site.ID)
|
||||||
|
|
||||||
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
||||||
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
|
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||||
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
|
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
|
||||||
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
if !inMaint {
|
||||||
|
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", site.Name))
|
||||||
|
}
|
||||||
newState.SentSSLWarning = true
|
newState.SentSSLWarning = true
|
||||||
} else if daysLeft > site.ExpiryThreshold {
|
} else if daysLeft > site.ExpiryThreshold {
|
||||||
newState.SentSSLWarning = false
|
newState.SentSSLWarning = false
|
||||||
@@ -405,14 +411,22 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
|
|
||||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||||
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
||||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
if inMaint {
|
||||||
if site.Type == "push" {
|
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
||||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
} else {
|
||||||
|
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
||||||
|
if site.Type == "push" {
|
||||||
|
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
||||||
|
}
|
||||||
|
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
||||||
}
|
}
|
||||||
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
|
||||||
}
|
}
|
||||||
if isBroken(site.Status) && newState.Status == "UP" {
|
if isBroken(site.Status) && newState.Status == "UP" {
|
||||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
if !inMaint {
|
||||||
|
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +446,24 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||||
|
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return inMaint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||||
|
if site.Paused {
|
||||||
|
return "PAUSED"
|
||||||
|
}
|
||||||
|
if e.isInMaintenance(site.ID) {
|
||||||
|
return "MAINT"
|
||||||
|
}
|
||||||
|
return site.Status
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) checkGroup(site models.Site) {
|
func (e *Engine) checkGroup(site models.Site) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
status := "UP"
|
status := "UP"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
.PENDING { background: #e0af68; color: #1a1b26; }
|
.PENDING { background: #e0af68; color: #1a1b26; }
|
||||||
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
||||||
.PAUSED { background: #565f89; color: #c0caf5; }
|
.PAUSED { background: #565f89; color: #c0caf5; }
|
||||||
|
.MAINT { background: #bb9af7; color: #1a1b26; }
|
||||||
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
|
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
|
||||||
.summary span { padding: 4px 12px; border-radius: 6px; }
|
.summary span { padding: 4px 12px; border-radius: 6px; }
|
||||||
.summary .s-up { color: #9ece6a; }
|
.summary .s-up { color: #9ece6a; }
|
||||||
@@ -68,15 +69,17 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(sites) {
|
function renderSummary(sites) {
|
||||||
var up = 0, down = 0, paused = 0, total = sites.length;
|
var up = 0, down = 0, paused = 0, maint = 0, total = sites.length;
|
||||||
for (var i = 0; i < sites.length; i++) {
|
for (var i = 0; i < sites.length; i++) {
|
||||||
if (sites[i].Paused) { paused++; continue; }
|
if (sites[i].Paused) { paused++; continue; }
|
||||||
|
if (sites[i].Status === 'MAINT') { maint++; continue; }
|
||||||
if (sites[i].Status === 'UP') up++;
|
if (sites[i].Status === 'UP') up++;
|
||||||
else if (sites[i].Status === 'DOWN') down++;
|
else if (sites[i].Status === 'DOWN') down++;
|
||||||
}
|
}
|
||||||
var el = document.getElementById('summary');
|
var el = document.getElementById('summary');
|
||||||
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
|
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
|
||||||
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
|
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
|
||||||
|
if (maint > 0) parts.push('<span style="color:#bb9af7">' + maint + ' MAINT</span>');
|
||||||
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
|
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
|
||||||
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
|
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
renderSummary(sites);
|
renderSummary(sites);
|
||||||
for (var i = 0; i < sites.length; i++) {
|
for (var i = 0; i < sites.length; i++) {
|
||||||
var s = sites[i];
|
var s = sites[i];
|
||||||
var st = s.Paused ? 'PAUSED' : s.Status;
|
var st = s.Status === 'MAINT' ? 'MAINT' : s.Paused ? 'PAUSED' : s.Status;
|
||||||
var cls = cssClass(st);
|
var cls = cssClass(st);
|
||||||
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
|
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
|
||||||
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
||||||
@@ -359,8 +362,24 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
|
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
|
||||||
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
state := eng.GetLiveState()
|
state := eng.GetLiveState()
|
||||||
|
activeWindows, _ := s.GetActiveMaintenanceWindows()
|
||||||
|
maintSet := make(map[int]bool)
|
||||||
|
allInMaint := false
|
||||||
|
for _, mw := range activeWindows {
|
||||||
|
if mw.Type != "maintenance" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mw.MonitorID == 0 {
|
||||||
|
allInMaint = true
|
||||||
|
} else {
|
||||||
|
maintSet[mw.MonitorID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
for id, site := range state {
|
for id, site := range state {
|
||||||
site.Token = ""
|
site.Token = ""
|
||||||
|
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
|
||||||
|
site.Status = "MAINT"
|
||||||
|
}
|
||||||
state[id] = site
|
state[id] = site
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
|||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
monitor_id INTEGER DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT DEFAULT 'maintenance',
|
||||||
|
start_time TIMESTAMP NOT NULL,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +98,12 @@ func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
|
|||||||
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE")
|
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE")
|
||||||
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE")
|
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE")
|
||||||
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
|
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
|
||||||
|
tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
|
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
|
||||||
tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))")
|
tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))")
|
||||||
tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))")
|
tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))")
|
||||||
tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))")
|
tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))")
|
||||||
|
tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
|||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
monitor_id INTEGER DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT DEFAULT 'maintenance',
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME,
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +107,8 @@ func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
|
|||||||
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
|
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
|
||||||
tx.Exec("DELETE FROM users")
|
tx.Exec("DELETE FROM users")
|
||||||
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'")
|
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'")
|
||||||
|
tx.Exec("DELETE FROM maintenance_windows")
|
||||||
|
tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
|
func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go-upkeep/internal/models"
|
"go-upkeep/internal/models"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SQLStore struct {
|
type SQLStore struct {
|
||||||
@@ -356,6 +357,90 @@ func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, erro
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
||||||
|
var mw models.MaintenanceWindow
|
||||||
|
var endTime sql.NullTime
|
||||||
|
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
||||||
|
return mw, err
|
||||||
|
}
|
||||||
|
if endTime.Valid {
|
||||||
|
mw.EndTime = endTime.Time
|
||||||
|
}
|
||||||
|
return mw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AddMaintenanceWindow(mw models.MaintenanceWindow) error {
|
||||||
|
if mw.StartTime.IsZero() {
|
||||||
|
mw.StartTime = time.Now()
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) EndMaintenanceWindow(id int) error {
|
||||||
|
_, err := s.db.Exec(s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) DeleteMaintenanceWindow(id int) error {
|
||||||
|
_, err := s.db.Exec(s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM maintenance_windows
|
||||||
|
WHERE type = 'maintenance'
|
||||||
|
AND start_time <= CURRENT_TIMESTAMP
|
||||||
|
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
||||||
|
AND (monitor_id = 0 OR monitor_id = ?
|
||||||
|
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
||||||
|
monitorID, monitorID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) ExportData() (models.Backup, error) {
|
func (s *SQLStore) ExportData() (models.Backup, error) {
|
||||||
sites, err := s.GetSites()
|
sites, err := s.GetSites()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -369,7 +454,11 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Backup{}, err
|
return models.Backup{}, err
|
||||||
}
|
}
|
||||||
return models.Backup{Sites: sites, Alerts: alerts, Users: users}, nil
|
windows, err := s.GetAllMaintenanceWindows(1000)
|
||||||
|
if err != nil {
|
||||||
|
return models.Backup{}, err
|
||||||
|
}
|
||||||
|
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) ImportData(data models.Backup) error {
|
func (s *SQLStore) ImportData(data models.Backup) error {
|
||||||
@@ -403,6 +492,13 @@ func (s *SQLStore) ImportData(data models.Backup) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, mw := range data.MaintenanceWindows {
|
||||||
|
if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.dialect.ImportResetSequences(tx)
|
s.dialect.ImportResetSequences(tx)
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ type Store interface {
|
|||||||
SaveLog(message string) error
|
SaveLog(message string) error
|
||||||
LoadLogs(limit int) ([]string, error)
|
LoadLogs(limit int) ([]string, error)
|
||||||
|
|
||||||
|
// Maintenance Windows
|
||||||
|
GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
|
||||||
|
GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
|
||||||
|
AddMaintenanceWindow(mw models.MaintenanceWindow) error
|
||||||
|
EndMaintenanceWindow(id int) error
|
||||||
|
DeleteMaintenanceWindow(id int) error
|
||||||
|
IsMonitorInMaintenance(monitorID int) (bool, error)
|
||||||
|
|
||||||
// Backup & Restore
|
// Backup & Restore
|
||||||
ExportData() (models.Backup, error)
|
ExportData() (models.Backup, error)
|
||||||
ImportData(data models.Backup) error
|
ImportData(data models.Backup) error
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -207,10 +207,13 @@ func fmtRetries(site models.Site) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtStatus(status string, paused bool) string {
|
func fmtStatus(status string, paused bool, inMaint bool) string {
|
||||||
if paused {
|
if paused {
|
||||||
return warnStyle.Render("PAUSED")
|
return warnStyle.Render("PAUSED")
|
||||||
}
|
}
|
||||||
|
if inMaint {
|
||||||
|
return maintStyle.Render("MAINT")
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case status == "DOWN" || status == "SSL EXP":
|
case status == "DOWN" || status == "SSL EXP":
|
||||||
return dangerStyle.Render(status)
|
return dangerStyle.Render(status)
|
||||||
@@ -280,7 +283,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
|
||||||
"group",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
|
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
|
||||||
@@ -313,7 +316,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
||||||
typeIcon(site.Type, false) + " " + site.Type,
|
typeIcon(site.Type, false) + " " + site.Type,
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
fmtLatency(site.Latency),
|
fmtLatency(site.Latency),
|
||||||
fmtUptime(hist.Statuses),
|
fmtUptime(hist.Statuses),
|
||||||
spark,
|
spark,
|
||||||
@@ -623,7 +626,15 @@ func (m Model) viewDetailPanel() string {
|
|||||||
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
|
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
|
||||||
}
|
}
|
||||||
|
|
||||||
row("Status", fmtStatus(site.Status, site.Paused))
|
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
||||||
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
|
row("Maintenance", maintStyle.Render(mw.Title))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
row("Type", site.Type)
|
row("Type", site.Type)
|
||||||
if site.URL != "" {
|
if site.URL != "" {
|
||||||
row("URL", site.URL)
|
row("URL", site.URL)
|
||||||
|
|||||||
+97
-20
@@ -42,6 +42,7 @@ const (
|
|||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
stateConfirmDelete
|
stateConfirmDelete
|
||||||
|
stateFormMaint
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -59,6 +60,7 @@ type Model struct {
|
|||||||
siteFormData *siteFormData
|
siteFormData *siteFormData
|
||||||
alertFormData *alertFormData
|
alertFormData *alertFormData
|
||||||
userFormData *userFormData
|
userFormData *userFormData
|
||||||
|
maintFormData *maintFormData
|
||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
@@ -78,10 +80,11 @@ type Model struct {
|
|||||||
pulseVel float64
|
pulseVel float64
|
||||||
tickCount int
|
tickCount int
|
||||||
|
|
||||||
sites []models.Site
|
sites []models.Site
|
||||||
alerts []models.AlertConfig
|
alerts []models.AlertConfig
|
||||||
users []models.User
|
users []models.User
|
||||||
nodes []models.ProbeNode
|
nodes []models.ProbeNode
|
||||||
|
maintenanceWindows []models.MaintenanceWindow
|
||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
@@ -128,7 +131,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.engine.AddLog("Delete alert failed: " + err.Error())
|
m.engine.AddLog("Delete alert failed: " + err.Error())
|
||||||
}
|
}
|
||||||
m.adjustCursor(len(m.alerts) - 1)
|
m.adjustCursor(len(m.alerts) - 1)
|
||||||
case 3:
|
case 4:
|
||||||
|
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||||
|
case 5:
|
||||||
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
||||||
m.engine.AddLog("Delete user failed: " + err.Error())
|
m.engine.AddLog("Delete user failed: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -136,12 +144,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.refreshData()
|
m.refreshData()
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 4 {
|
if m.deleteTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
case "n", "N", "esc":
|
case "n", "N", "esc":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 4 {
|
if m.deleteTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
@@ -152,7 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
if keyMsg.String() == "ctrl+c" {
|
if keyMsg.String() == "ctrl+c" {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -160,7 +168,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if keyMsg.String() == "esc" {
|
if keyMsg.String() == "esc" {
|
||||||
m.huhForm = nil
|
m.huhForm = nil
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -226,6 +234,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else if m.currentTab == 3 {
|
} else if m.currentTab == 3 {
|
||||||
listLen = len(m.nodes)
|
listLen = len(m.nodes)
|
||||||
} else if m.currentTab == 4 {
|
} else if m.currentTab == 4 {
|
||||||
|
listLen = len(m.maintenanceWindows)
|
||||||
|
} else if m.currentTab == 5 {
|
||||||
listLen = len(m.users)
|
listLen = len(m.users)
|
||||||
}
|
}
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
@@ -331,6 +341,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
max = len(m.nodes) - 1
|
max = len(m.nodes) - 1
|
||||||
}
|
}
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 4 {
|
||||||
|
max = len(m.maintenanceWindows) - 1
|
||||||
|
}
|
||||||
|
if m.currentTab == 5 {
|
||||||
max = len(m.users) - 1
|
max = len(m.users) - 1
|
||||||
}
|
}
|
||||||
if m.cursor < max {
|
if m.cursor < max {
|
||||||
@@ -349,7 +362,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else if m.currentTab == 1 {
|
} else if m.currentTab == 1 {
|
||||||
m.state = stateFormAlert
|
m.state = stateFormAlert
|
||||||
return m, m.initAlertHuhForm()
|
return m, m.initAlertHuhForm()
|
||||||
} else if m.currentTab == 4 && m.isAdmin {
|
} else if m.currentTab == 4 {
|
||||||
|
m.state = stateFormMaint
|
||||||
|
return m, m.initMaintHuhForm()
|
||||||
|
} else if m.currentTab == 5 && m.isAdmin {
|
||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
}
|
}
|
||||||
@@ -363,7 +379,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editID = m.alerts[m.cursor].ID
|
m.editID = m.alerts[m.cursor].ID
|
||||||
m.state = stateFormAlert
|
m.state = stateFormAlert
|
||||||
return m, m.initAlertHuhForm()
|
return m, m.initAlertHuhForm()
|
||||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||||
m.editID = m.users[m.cursor].ID
|
m.editID = m.users[m.cursor].ID
|
||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
@@ -386,6 +402,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
|
case "x":
|
||||||
|
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
|
mw := m.maintenanceWindows[m.cursor]
|
||||||
|
now := time.Now()
|
||||||
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||||
|
if isActive {
|
||||||
|
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
||||||
|
m.engine.AddLog("End maintenance failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
case "d", "backspace":
|
case "d", "backspace":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
@@ -397,10 +425,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.deleteName = m.alerts[m.cursor].Name
|
m.deleteName = m.alerts[m.cursor].Name
|
||||||
m.deleteTab = 1
|
m.deleteTab = 1
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
} else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
|
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||||
|
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||||
|
m.deleteTab = 4
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||||
m.deleteID = m.users[m.cursor].ID
|
m.deleteID = m.users[m.cursor].ID
|
||||||
m.deleteName = m.users[m.cursor].Username
|
m.deleteName = m.users[m.cursor].Username
|
||||||
m.deleteTab = 4
|
m.deleteTab = 5
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,9 +443,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
tabCount := 4
|
tabCount := 5
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
tabCount = 5
|
tabCount = 6
|
||||||
}
|
}
|
||||||
for i := 0; i < tabCount; i++ {
|
for i := 0; i < tabCount; i++ {
|
||||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||||
@@ -448,6 +481,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 4 {
|
||||||
|
end := m.tableOffset + m.maxTableRows
|
||||||
|
if end > len(m.maintenanceWindows) {
|
||||||
|
end = len(m.maintenanceWindows)
|
||||||
|
}
|
||||||
|
for i := m.tableOffset; i < end; i++ {
|
||||||
|
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
|
||||||
|
m.cursor = i
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.currentTab == 5 {
|
||||||
end := m.tableOffset + m.maxTableRows
|
end := m.tableOffset + m.maxTableRows
|
||||||
if end > len(m.users) {
|
if end > len(m.users) {
|
||||||
end = len(m.users)
|
end = len(m.users)
|
||||||
@@ -464,9 +510,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) switchTab(idx int) {
|
func (m *Model) switchTab(idx int) {
|
||||||
maxTabs := 3
|
maxTabs := 4
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
maxTabs = 4
|
maxTabs = 5
|
||||||
}
|
}
|
||||||
if idx > maxTabs {
|
if idx > maxTabs {
|
||||||
idx = 0
|
idx = 0
|
||||||
@@ -477,7 +523,7 @@ func (m *Model) switchTab(idx int) {
|
|||||||
switch idx {
|
switch idx {
|
||||||
case 2:
|
case 2:
|
||||||
m.state = stateLogs
|
m.state = stateLogs
|
||||||
case 4:
|
case 5:
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
default:
|
default:
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
@@ -550,6 +596,9 @@ func (m *Model) refreshData() {
|
|||||||
if nodes, err := m.store.GetAllNodes(); err == nil {
|
if nodes, err := m.store.GetAllNodes(); err == nil {
|
||||||
m.nodes = nodes
|
m.nodes = nodes
|
||||||
}
|
}
|
||||||
|
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
||||||
|
m.maintenanceWindows = windows
|
||||||
|
}
|
||||||
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
||||||
|
|
||||||
listLen := len(m.sites)
|
listLen := len(m.sites)
|
||||||
@@ -558,6 +607,8 @@ func (m *Model) refreshData() {
|
|||||||
} else if m.currentTab == 3 {
|
} else if m.currentTab == 3 {
|
||||||
listLen = len(m.nodes)
|
listLen = len(m.nodes)
|
||||||
} else if m.currentTab == 4 {
|
} else if m.currentTab == 4 {
|
||||||
|
listLen = len(m.maintenanceWindows)
|
||||||
|
} else if m.currentTab == 5 {
|
||||||
listLen = len(m.users)
|
listLen = len(m.users)
|
||||||
}
|
}
|
||||||
if listLen > 0 && m.cursor >= listLen {
|
if listLen > 0 && m.cursor >= listLen {
|
||||||
@@ -582,6 +633,10 @@ func (m *Model) submitForm() {
|
|||||||
if m.userFormData != nil {
|
if m.userFormData != nil {
|
||||||
m.submitUserForm()
|
m.submitUserForm()
|
||||||
}
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
if m.maintFormData != nil {
|
||||||
|
m.submitMaintForm()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +669,8 @@ func (m Model) View() string {
|
|||||||
if m.deleteTab == 1 {
|
if m.deleteTab == 1 {
|
||||||
kind = "alert"
|
kind = "alert"
|
||||||
} else if m.deleteTab == 4 {
|
} else if m.deleteTab == 4 {
|
||||||
|
kind = "maintenance window"
|
||||||
|
} else if m.deleteTab == 5 {
|
||||||
kind = "user"
|
kind = "user"
|
||||||
}
|
}
|
||||||
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||||
@@ -624,7 +681,7 @@ func (m Model) View() string {
|
|||||||
Padding(1, 3).
|
Padding(1, 3).
|
||||||
Render(msg + "\n\n" + hint)
|
Render(msg + "\n\n" + hint)
|
||||||
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
||||||
case stateFormSite, stateFormAlert, stateFormUser:
|
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
||||||
if m.huhForm != nil {
|
if m.huhForm != nil {
|
||||||
title := ""
|
title := ""
|
||||||
switch m.state {
|
switch m.state {
|
||||||
@@ -643,6 +700,8 @@ func (m Model) View() string {
|
|||||||
if m.editID > 0 {
|
if m.editID > 0 {
|
||||||
title = fmt.Sprintf("Edit User #%d", m.editID)
|
title = fmt.Sprintf("Edit User #%d", m.editID)
|
||||||
}
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
title = "New Maintenance Window"
|
||||||
}
|
}
|
||||||
header := titleStyle.Render(title)
|
header := titleStyle.Render(title)
|
||||||
footer := subtleStyle.Render("\n[Esc] Cancel")
|
footer := subtleStyle.Render("\n[Esc] Cancel")
|
||||||
@@ -687,7 +746,21 @@ func (m Model) viewDashboard() string {
|
|||||||
nodesLabel = "Nodes"
|
nodesLabel = "Nodes"
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel}
|
activeMaint := 0
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
now := time.Now()
|
||||||
|
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
||||||
|
activeMaint++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var maintLabel string
|
||||||
|
if activeMaint > 0 {
|
||||||
|
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
|
||||||
|
} else {
|
||||||
|
maintLabel = "Maint"
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
tabs = append(tabs, "Users")
|
tabs = append(tabs, "Users")
|
||||||
}
|
}
|
||||||
@@ -717,6 +790,8 @@ func (m Model) viewDashboard() string {
|
|||||||
case 3:
|
case 3:
|
||||||
content = m.viewNodesTab()
|
content = m.viewNodesTab()
|
||||||
case 4:
|
case 4:
|
||||||
|
content = m.viewMaintTab()
|
||||||
|
case 5:
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
content = m.viewUsersTab()
|
content = m.viewUsersTab()
|
||||||
}
|
}
|
||||||
@@ -751,6 +826,8 @@ func (m Model) viewDashboard() string {
|
|||||||
case 0:
|
case 0:
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
||||||
case 4:
|
case 4:
|
||||||
|
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit"
|
||||||
|
case 5:
|
||||||
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
||||||
default:
|
default:
|
||||||
keys = "[Tab]Switch [q]Quit"
|
keys = "[Tab]Switch [q]Quit"
|
||||||
|
|||||||
Reference in New Issue
Block a user