Merge pull request 'feat: incident management and maintenance windows' (#17) from feat/incident-management into main
Reviewed-on: lerko/uptime#17
This commit was merged in pull request #17.
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -385,10 +385,16 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
||||
newState.FailureCount = site.MaxRetries + 1
|
||||
}
|
||||
|
||||
inMaint := e.isInMaintenance(site.ID)
|
||||
|
||||
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
||||
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
|
||||
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
||||
if !inMaint {
|
||||
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", site.Name))
|
||||
}
|
||||
newState.SentSSLWarning = true
|
||||
} else if daysLeft > site.ExpiryThreshold {
|
||||
newState.SentSSLWarning = false
|
||||
@@ -405,14 +411,22 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
||||
|
||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
||||
if site.Type == "push" {
|
||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
||||
if inMaint {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
||||
} else {
|
||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
||||
if site.Type == "push" {
|
||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
||||
}
|
||||
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
||||
}
|
||||
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
||||
}
|
||||
if isBroken(site.Status) && newState.Status == "UP" {
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||
if !inMaint {
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +446,24 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return inMaint
|
||||
}
|
||||
|
||||
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||
if site.Paused {
|
||||
return "PAUSED"
|
||||
}
|
||||
if e.isInMaintenance(site.ID) {
|
||||
return "MAINT"
|
||||
}
|
||||
return site.Status
|
||||
}
|
||||
|
||||
func (e *Engine) checkGroup(site models.Site) {
|
||||
e.mu.RLock()
|
||||
status := "UP"
|
||||
@@ -445,7 +477,7 @@ func (e *Engine) checkGroup(site models.Site) {
|
||||
if !child.Paused {
|
||||
allPaused = false
|
||||
}
|
||||
if child.Paused {
|
||||
if child.Paused || e.isInMaintenance(child.ID) {
|
||||
continue
|
||||
}
|
||||
if child.Status == "DOWN" || child.Status == "SSL EXP" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+108
-22
@@ -42,6 +42,7 @@ const (
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
stateConfirmDelete
|
||||
stateFormMaint
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -59,6 +60,7 @@ type Model struct {
|
||||
siteFormData *siteFormData
|
||||
alertFormData *alertFormData
|
||||
userFormData *userFormData
|
||||
maintFormData *maintFormData
|
||||
|
||||
logViewport viewport.Model
|
||||
isAdmin bool
|
||||
@@ -78,10 +80,11 @@ type Model struct {
|
||||
pulseVel float64
|
||||
tickCount int
|
||||
|
||||
sites []models.Site
|
||||
alerts []models.AlertConfig
|
||||
users []models.User
|
||||
nodes []models.ProbeNode
|
||||
sites []models.Site
|
||||
alerts []models.AlertConfig
|
||||
users []models.User
|
||||
nodes []models.ProbeNode
|
||||
maintenanceWindows []models.MaintenanceWindow
|
||||
|
||||
filterMode bool
|
||||
filterText string
|
||||
@@ -128,7 +131,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.engine.AddLog("Delete alert failed: " + err.Error())
|
||||
}
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
case 3:
|
||||
case 4:
|
||||
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
||||
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
||||
}
|
||||
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||
case 5:
|
||||
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
||||
m.engine.AddLog("Delete user failed: " + err.Error())
|
||||
}
|
||||
@@ -136,12 +144,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.refreshData()
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 4 {
|
||||
if m.deleteTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
case "n", "N", "esc":
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 4 {
|
||||
if m.deleteTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
case "ctrl+c":
|
||||
@@ -152,7 +160,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.termWidth = wsm.Width
|
||||
m.termHeight = wsm.Height
|
||||
}
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
@@ -160,7 +172,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if keyMsg.String() == "esc" {
|
||||
m.huhForm = nil
|
||||
m.state = stateDashboard
|
||||
if m.currentTab == 4 {
|
||||
if m.currentTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
return m, nil
|
||||
@@ -226,6 +238,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else if m.currentTab == 3 {
|
||||
listLen = len(m.nodes)
|
||||
} else if m.currentTab == 4 {
|
||||
listLen = len(m.maintenanceWindows)
|
||||
} else if m.currentTab == 5 {
|
||||
listLen = len(m.users)
|
||||
}
|
||||
if msg.Button == tea.MouseButtonWheelUp {
|
||||
@@ -331,6 +345,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
max = len(m.nodes) - 1
|
||||
}
|
||||
if m.currentTab == 4 {
|
||||
max = len(m.maintenanceWindows) - 1
|
||||
}
|
||||
if m.currentTab == 5 {
|
||||
max = len(m.users) - 1
|
||||
}
|
||||
if m.cursor < max {
|
||||
@@ -349,7 +366,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else if m.currentTab == 1 {
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
} else if m.currentTab == 4 && m.isAdmin {
|
||||
} else if m.currentTab == 4 {
|
||||
m.state = stateFormMaint
|
||||
return m, m.initMaintHuhForm()
|
||||
} else if m.currentTab == 5 && m.isAdmin {
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
}
|
||||
@@ -363,7 +383,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.editID = m.alerts[m.cursor].ID
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
||||
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||
m.editID = m.users[m.cursor].ID
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
@@ -386,6 +406,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.state = stateDetail
|
||||
}
|
||||
case "x":
|
||||
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||
mw := m.maintenanceWindows[m.cursor]
|
||||
now := time.Now()
|
||||
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||
if isActive {
|
||||
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
||||
m.engine.AddLog("End maintenance failed: " + err.Error())
|
||||
}
|
||||
m.refreshData()
|
||||
}
|
||||
}
|
||||
case "d", "backspace":
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.deleteID = m.sites[m.cursor].ID
|
||||
@@ -397,10 +429,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.deleteName = m.alerts[m.cursor].Name
|
||||
m.deleteTab = 1
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
||||
} else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||
m.deleteTab = 4
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||
m.deleteID = m.users[m.cursor].ID
|
||||
m.deleteName = m.users[m.cursor].Username
|
||||
m.deleteTab = 4
|
||||
m.deleteTab = 5
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
}
|
||||
@@ -410,9 +447,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
tabCount := 4
|
||||
tabCount := 5
|
||||
if m.isAdmin {
|
||||
tabCount = 5
|
||||
tabCount = 6
|
||||
}
|
||||
for i := 0; i < tabCount; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||
@@ -448,6 +485,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if m.currentTab == 4 {
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.maintenanceWindows) {
|
||||
end = len(m.maintenanceWindows)
|
||||
}
|
||||
for i := m.tableOffset; i < end; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
|
||||
m.cursor = i
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentTab == 5 {
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.users) {
|
||||
end = len(m.users)
|
||||
@@ -464,9 +514,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) switchTab(idx int) {
|
||||
maxTabs := 3
|
||||
maxTabs := 4
|
||||
if m.isAdmin {
|
||||
maxTabs = 4
|
||||
maxTabs = 5
|
||||
}
|
||||
if idx > maxTabs {
|
||||
idx = 0
|
||||
@@ -477,7 +527,7 @@ func (m *Model) switchTab(idx int) {
|
||||
switch idx {
|
||||
case 2:
|
||||
m.state = stateLogs
|
||||
case 4:
|
||||
case 5:
|
||||
m.state = stateUsers
|
||||
default:
|
||||
m.state = stateDashboard
|
||||
@@ -550,6 +600,9 @@ func (m *Model) refreshData() {
|
||||
if nodes, err := m.store.GetAllNodes(); err == nil {
|
||||
m.nodes = nodes
|
||||
}
|
||||
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
||||
m.maintenanceWindows = windows
|
||||
}
|
||||
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
||||
|
||||
listLen := len(m.sites)
|
||||
@@ -558,6 +611,8 @@ func (m *Model) refreshData() {
|
||||
} else if m.currentTab == 3 {
|
||||
listLen = len(m.nodes)
|
||||
} else if m.currentTab == 4 {
|
||||
listLen = len(m.maintenanceWindows)
|
||||
} else if m.currentTab == 5 {
|
||||
listLen = len(m.users)
|
||||
}
|
||||
if listLen > 0 && m.cursor >= listLen {
|
||||
@@ -582,6 +637,10 @@ func (m *Model) submitForm() {
|
||||
if m.userFormData != nil {
|
||||
m.submitUserForm()
|
||||
}
|
||||
case stateFormMaint:
|
||||
if m.maintFormData != nil {
|
||||
m.submitMaintForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +652,7 @@ func (m Model) pulseIndicator() string {
|
||||
}
|
||||
hasDown := false
|
||||
for _, s := range m.sites {
|
||||
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||
hasDown = true
|
||||
break
|
||||
}
|
||||
@@ -614,6 +673,8 @@ func (m Model) View() string {
|
||||
if m.deleteTab == 1 {
|
||||
kind = "alert"
|
||||
} else if m.deleteTab == 4 {
|
||||
kind = "maintenance window"
|
||||
} else if m.deleteTab == 5 {
|
||||
kind = "user"
|
||||
}
|
||||
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||
@@ -624,7 +685,7 @@ func (m Model) View() string {
|
||||
Padding(1, 3).
|
||||
Render(msg + "\n\n" + hint)
|
||||
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
||||
case stateFormSite, stateFormAlert, stateFormUser:
|
||||
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
||||
if m.huhForm != nil {
|
||||
title := ""
|
||||
switch m.state {
|
||||
@@ -643,7 +704,14 @@ func (m Model) View() string {
|
||||
if m.editID > 0 {
|
||||
title = fmt.Sprintf("Edit User #%d", m.editID)
|
||||
}
|
||||
case stateFormMaint:
|
||||
title = "New Maintenance Window"
|
||||
}
|
||||
formHeight := m.termHeight - 7
|
||||
if formHeight < 5 {
|
||||
formHeight = 5
|
||||
}
|
||||
m.huhForm.WithHeight(formHeight)
|
||||
header := titleStyle.Render(title)
|
||||
footer := subtleStyle.Render("\n[Esc] Cancel")
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||
@@ -659,7 +727,7 @@ func (m Model) View() string {
|
||||
func (m Model) viewDashboard() string {
|
||||
downCount := 0
|
||||
for _, s := range m.sites {
|
||||
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||
downCount++
|
||||
}
|
||||
}
|
||||
@@ -687,7 +755,21 @@ func (m Model) viewDashboard() string {
|
||||
nodesLabel = "Nodes"
|
||||
}
|
||||
|
||||
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel}
|
||||
activeMaint := 0
|
||||
for _, mw := range m.maintenanceWindows {
|
||||
now := time.Now()
|
||||
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
||||
activeMaint++
|
||||
}
|
||||
}
|
||||
var maintLabel string
|
||||
if activeMaint > 0 {
|
||||
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
|
||||
} else {
|
||||
maintLabel = "Maint"
|
||||
}
|
||||
|
||||
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
||||
if m.isAdmin {
|
||||
tabs = append(tabs, "Users")
|
||||
}
|
||||
@@ -717,6 +799,8 @@ func (m Model) viewDashboard() string {
|
||||
case 3:
|
||||
content = m.viewNodesTab()
|
||||
case 4:
|
||||
content = m.viewMaintTab()
|
||||
case 5:
|
||||
if m.isAdmin {
|
||||
content = m.viewUsersTab()
|
||||
}
|
||||
@@ -751,6 +835,8 @@ func (m Model) viewDashboard() string {
|
||||
case 0:
|
||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
||||
case 4:
|
||||
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit"
|
||||
case 5:
|
||||
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
||||
default:
|
||||
keys = "[Tab]Switch [q]Quit"
|
||||
|
||||
Reference in New Issue
Block a user