feat: add incident management and maintenance windows
Maintenance windows suppress alerts during planned downtime while checks continue running. Incidents provide informational tracking. Supports targeting all monitors, single monitor, or group (applies to children). New Maint tab in TUI with create/end/delete. Status page, JSON API, and Prometheus metrics all reflect maintenance state.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user