a5fd3aec90
Thread context.Context from callers instead of creating context.Background() at every call site. Engine stores its lifecycle ctx for use by triggerAlert goroutines. TUI Model carries ctx for store operations in Cmd closures. CLI commands create a root ctx and thread it through openStore, seed functions, and init methods. Only two intentional context.Background() remain in non-root positions: engine constructor default (overridden by Start) and drainWrites (parent already cancelled at shutdown).
266 lines
6.6 KiB
Go
266 lines
6.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/huh"
|
|
)
|
|
|
|
type maintFormData struct {
|
|
Title string
|
|
Description string
|
|
Type string
|
|
MonitorID string
|
|
Duration string
|
|
CustomHours string
|
|
}
|
|
|
|
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
|
|
now := time.Now()
|
|
if mw.StartTime.After(now) {
|
|
return m.st.warnStyle.Render("SCHEDULED")
|
|
}
|
|
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
|
return m.st.subtleStyle.Render("ENDED")
|
|
}
|
|
return m.st.specialStyle.Render("ACTIVE")
|
|
}
|
|
|
|
func (m Model) fmtMaintType(t string) string {
|
|
if t == "incident" {
|
|
return m.st.dangerStyle.Render("incident")
|
|
}
|
|
return m.st.maintStyle.Render("maintenance")
|
|
}
|
|
|
|
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
|
|
if monitorID == 0 {
|
|
return "All"
|
|
}
|
|
for _, s := range sites {
|
|
if s.ID == monitorID {
|
|
return limitStr(s.Name, maxW)
|
|
}
|
|
}
|
|
return fmt.Sprintf("#%d", monitorID)
|
|
}
|
|
|
|
func (m Model) fmtMaintTime(t time.Time, colW int) string {
|
|
if t.IsZero() {
|
|
return m.st.subtleStyle.Render("—")
|
|
}
|
|
now := time.Now()
|
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
|
return t.Format("15:04")
|
|
}
|
|
if colW >= 14 {
|
|
return t.Format("15:04 Jan 02")
|
|
}
|
|
return t.Format("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 m.emptyState("No maintenance windows or incidents.", "[n] Create one")
|
|
}
|
|
|
|
var headers []string
|
|
var widths []int
|
|
if m.isWide() {
|
|
headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}
|
|
widths = []int{4, 24, 14, 22, 12, 16, 16}
|
|
} else {
|
|
headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"}
|
|
widths = []int{4, 14, 13, 14, 11, 14, 14}
|
|
}
|
|
titleW := widths[1]
|
|
monW := widths[3]
|
|
timeW := widths[5]
|
|
|
|
tbl := m.renderTable(
|
|
headers,
|
|
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, titleW-2)),
|
|
m.fmtMaintType(mw.Type),
|
|
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
|
|
m.fmtMaintStatus(mw),
|
|
m.fmtMaintTime(mw.StartTime, timeW),
|
|
m.fmtMaintTime(mw.EndTime, timeW),
|
|
})
|
|
}
|
|
return rows
|
|
},
|
|
widths,
|
|
nil,
|
|
)
|
|
|
|
now := time.Now()
|
|
var active, scheduled, ended int
|
|
for _, mw := range m.maintenanceWindows {
|
|
if mw.StartTime.After(now) {
|
|
scheduled++
|
|
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
|
ended++
|
|
} else {
|
|
active++
|
|
}
|
|
}
|
|
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
|
|
|
|
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
|
}
|
|
|
|
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(m.theme.HuhTheme())
|
|
|
|
return m.huhForm.Init()
|
|
}
|
|
|
|
func (m *Model) submitMaintForm() tea.Cmd {
|
|
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)
|
|
}
|
|
}
|
|
|
|
st := m.store
|
|
ctx := m.ctx
|
|
m.state = stateDashboard
|
|
return writeCmd("Add maintenance window", func() error {
|
|
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
|
|
if len(overlaps) > 0 {
|
|
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
|
|
}
|
|
return st.AddMaintenanceWindow(ctx, mw)
|
|
})
|
|
}
|