Files
uptop/internal/tui/update.go
T
lerko f00acbc280 refactor(models): typed Status constants with IsBroken() predicate
Replace ~150 bare status string comparisons with typed models.Status
constants (StatusUp, StatusDown, StatusPending, StatusLate, StatusStale,
StatusSSLExp). Single IsBroken() method replaces the duplicated
isBroken lambda in monitor.go and isDown function in sla.go.

Adding a new status value (e.g. DEGRADED) now requires one constant
definition instead of grep-and-pray across 16 files.

CheckResult.Status stays string — the checker is the boundary between
raw protocol results and typed status. Cast happens at the edge in
handleStatusChange.
2026-06-11 15:56:51 -04:00

811 lines
19 KiB
Go

package tui
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m.handleResize(msg)
case tickMsg:
return m.handleTick(time.Time(msg))
case tabDataMsg:
return m.handleTabData(msg)
case detailDataMsg:
// Drop replies for a site the user has already navigated away from,
// so a slow load can't clobber the panel currently on screen.
if m.state == stateDetail && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil
}
m.detailChanges = msg.changes
m.detailChangesSiteID = msg.siteID
return m, nil
case historyDataMsg:
if msg.siteID != m.historySiteID {
return m, nil // stale reply for a previously opened history
}
m.historyChanges = msg.changes
m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
return m, nil
case slaDataMsg:
return m.handleSLAData(msg)
case writeDoneMsg:
if msg.err != nil {
m.engine.AddLog(msg.op + " failed: " + msg.err.Error())
}
m.refreshLive()
return m, m.loadTabDataCmd()
}
if m.state == stateConfirmDelete {
return m.handleConfirmDelete(msg)
}
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
return m.handleFormMsg(msg)
}
switch msg := msg.(type) {
case tea.MouseMsg:
return m.handleMouse(msg)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "y", "Y":
// The store delete runs in a Cmd; the in-memory engine/model updates
// stay here so the row vanishes immediately. If the delete fails, the
// writeDoneMsg reload converges the UI back to the DB state (and the
// engine poll loop re-adds a site that is still in the DB).
st := m.store
id := m.deleteID
var cmd tea.Cmd
switch m.deleteTab {
case 0:
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1)
case 1:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
m.adjustCursor(len(m.alerts) - 1)
case 4:
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5:
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
m.adjustCursor(len(m.users) - 1)
}
m.refreshLive()
m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
return m, cmd
case "n", "N", "esc":
m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
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
}
if keyMsg.String() == "esc" {
m.huhForm = nil
m.state = stateDashboard
if m.currentTab == 5 {
m.state = stateUsers
}
return m, nil
}
}
if m.huhForm != nil {
form, formCmd := m.huhForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.huhForm = f
}
if m.huhForm.State == huh.StateCompleted {
// The store write runs in the returned Cmd; its writeDoneMsg
// triggers the tab-data reload once the row actually exists.
cmd := m.submitForm()
m.refreshLive()
m.huhForm = nil
return m, cmd
}
return m, formCmd
}
return m, nil
}
func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.termWidth = msg.Width
m.termHeight = msg.Height
chrome := chromeBase
if m.filterMode || m.filterText != "" {
chrome++
}
m.maxTableRows = msg.Height - chrome
if m.maxTableRows < 1 {
m.maxTableRows = 1
}
m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2)
m.historyViewport.Width = msg.Width - chromePadH
m.historyViewport.Height = msg.Height - 10
m.slaViewport.Width = msg.Width - chromePadH
m.slaViewport.Height = msg.Height - 16
return m, tea.ClearScreen
}
func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
m.refreshLive()
m.tickCount++
target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
cmds := []tea.Cmd{tickCmd()}
if t.Sub(m.lastTabLoad) > tabRefreshTTL {
m.lastTabLoad = t
cmds = append(cmds, m.loadTabDataCmd())
if dc := m.detailRefreshCmd(); dc != nil {
cmds = append(cmds, dc)
}
}
return m, tea.Batch(cmds...)
}
// detailRefreshCmd reloads the open detail panel's state-change list on the
// tab-data cadence, so a flap that happens while the panel is on screen shows
// up without leaving and re-entering. Nil when no detail panel is open.
func (m *Model) detailRefreshCmd() tea.Cmd {
if m.state != stateDetail || m.cursor >= len(m.sites) {
return nil
}
return m.loadDetailCmd(m.sites[m.cursor].ID)
}
// handleTabData folds an async tab-data load into the model. Replies older
// than the newest issued load are dropped so out-of-order completions can't
// overwrite fresher data. On error the previous data is kept and the failure
// logged, so a transient store error never blanks the view.
func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
if msg.seq != m.tabSeq {
return m, nil
}
if msg.err != nil {
m.engine.AddLog("Tab data refresh failed: " + msg.err.Error())
return m, nil
}
m.alerts = msg.alerts
if m.isAdmin {
m.users = msg.users
}
m.nodes = msg.nodes
m.maintenanceWindows = msg.maint
m.clampCursor()
return m, nil
}
// testAlertCmd sends a test notification off the UI goroutine; the outcome
// surfaces through the engine log (picked up by the next refreshLive).
func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
eng := m.engine
return func() tea.Msg {
if err := eng.TestAlert(id); err != nil {
eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err))
}
return nil
}
}
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.state == stateHistory {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.historyViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.historyViewport.ScrollDown(3)
}
return m, nil
}
if m.state == stateSLA {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.slaViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.slaViewport.ScrollDown(3)
}
return m, nil
}
if m.state == stateDetail {
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
return m.handleSparklineClick(msg)
}
return m, nil
}
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
return m, nil
}
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
return m.handleClick(msg)
}
if msg.Button != tea.MouseButtonWheelUp && msg.Button != tea.MouseButtonWheelDown {
return m, nil
}
if m.state == stateLogs {
if msg.Button == tea.MouseButtonWheelUp {
m.logViewport.ScrollUp(3)
} else {
m.logViewport.ScrollDown(3)
}
return m, nil
}
listLen := m.currentListLen()
if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 {
m.cursor--
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
}
} else {
if m.cursor < listLen-1 {
m.cursor++
if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset++
}
}
}
return m, nil
}
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if msg.String() == "ctrl+l" {
return m, tea.ClearScreen
}
if m.filterMode {
return m.handleFilterKey(msg)
}
switch m.state {
case stateDetail:
return m.handleDetailKey(msg)
case stateHistory:
return m.handleHistoryKey(msg)
case stateSLA:
return m.handleSLAKey(msg)
case stateAlertDetail:
return m.handleAlertDetailKey(msg)
case stateDashboard, stateLogs, stateUsers:
return m.handleDashboardKey(msg)
}
return m, nil
}
func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.filterMode = false
m.filterText = ""
m.cursor = 0
m.tableOffset = 0
m.refreshLive()
case "enter":
m.filterMode = false
case "backspace":
if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1]
m.cursor = 0
m.tableOffset = 0
m.refreshLive()
}
case "ctrl+c":
return m, tea.Quit
default:
if len(msg.String()) == 1 {
m.filterText += msg.String()
m.cursor = 0
m.tableOffset = 0
m.refreshLive()
}
}
return m, nil
}
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.sparkTooltipIdx >= 0 {
m.sparkTooltipIdx = -1
return m, nil
}
m.sparkTooltipIdx = -1
m.state = stateDashboard
case "i":
m.sparkTooltipIdx = -1
m.state = stateDashboard
case "e":
return m.handleEditItem()
case "h":
if m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "q":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleSparklineClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.cursor >= len(m.sites) {
return m, nil
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
const sparkWidth = 40
if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, sparkWidth, len(hist.Latencies))
return m, nil
}
if zi := m.zones.Get("spark-heartbeat"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, sparkWidth, len(hist.Statuses))
return m, nil
}
m.sparkTooltipIdx = -1
return m, nil
}
func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
m.state = stateDetail
case "1", "2", "3", "4":
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
return m, m.loadSLACmd(m.slaSiteID, idx)
}
case "up", "k":
m.slaViewport.ScrollUp(1)
case "down", "j":
m.slaViewport.ScrollDown(1)
case "pgup":
m.slaViewport.HalfPageUp()
case "pgdown":
m.slaViewport.HalfPageDown()
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) openSLAView(site models.Site) tea.Cmd {
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2 // default 30d
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent("\n Loading SLA report...")
m.state = stateSLA
return m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
// handleSLAData folds an async SLA load into the model. The SLA math itself is
// pure CPU and cheap, so it runs here; only the state-change read happens in
// the Cmd. Replies for a different site or period than currently selected are
// stale and dropped.
func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
if msg.siteID != m.slaSiteID || msg.periodIdx != m.slaPeriodIdx {
return m, nil
}
period := slaPeriods[msg.periodIdx]
var currentStatus models.Status
for _, s := range m.sites {
if s.ID == msg.siteID {
currentStatus = s.Status
break
}
}
m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration)
m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now())
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent(m.buildSLADailyContent())
m.slaViewport.GotoTop()
return m, nil
}
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
m.state = stateDetail
case "up", "k":
m.historyViewport.ScrollUp(1)
case "down", "j":
m.historyViewport.ScrollDown(1)
case "pgup":
m.historyViewport.HalfPageUp()
case "pgdown":
m.historyViewport.HalfPageDown()
case "home", "g":
m.historyViewport.GotoTop()
case "end", "G":
m.historyViewport.GotoBottom()
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i", "esc":
m.state = stateDashboard
case "q":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg.String() {
case "q":
return m, tea.Quit
case "/":
if m.currentTab == 0 {
m.filterMode = true
return m, nil
}
case "f":
if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant
return m, nil
}
case "tab":
m.switchTab(m.currentTab + 1)
case "pgup", "pgdown":
if m.state == stateLogs {
m.logViewport, cmd = m.logViewport.Update(msg)
return m, cmd
}
case "up", "k":
if m.state == stateLogs {
m.logViewport.ScrollUp(1)
} else if m.cursor > 0 {
m.cursor--
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
}
case "down", "j":
if m.state == stateLogs {
m.logViewport.ScrollDown(1)
} else {
max := m.currentListLen() - 1
if m.cursor < max {
m.cursor++
if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset++
}
}
}
case "n":
return m.handleNewItem()
case "e", "enter":
return m.handleEditItem()
case "t":
if m.currentTab == 1 && len(m.alerts) > 0 {
a := m.alerts[m.cursor]
return m, m.testAlertCmd(a.ID, a.Name)
}
case " ":
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid]
payload := collapsedJSON(m.collapsed)
st := m.store
m.refreshLive()
return m, writeCmd("Save collapsed groups", func() error {
return st.SetPreference(context.Background(), "collapsed_groups", payload)
})
}
case "p":
if m.currentTab == 0 && len(m.sites) > 0 {
id := m.sites[m.cursor].ID
paused := m.engine.ToggleSitePause(id)
st := m.store
m.refreshLive()
return m, writeCmd("Update pause state", func() error {
return st.UpdateSitePaused(context.Background(), id, paused)
})
}
case "i":
if m.currentTab == 0 && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} else if m.currentTab == 1 && len(m.alerts) > 0 {
m.state = stateAlertDetail
}
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 {
st := m.store
id := mw.ID
m.refreshLive()
return m, writeCmd("End maintenance", func() error {
return st.EndMaintenanceWindow(context.Background(), id)
})
}
}
case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex]
m.st = newStyles(m.theme)
st := m.store
name := m.theme.Name
return m, writeCmd("Save theme", func() error {
return st.SetPreference(context.Background(), "theme", name)
})
case "d", "backspace":
return m.handleDeleteItem()
}
return m, nil
}
func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0
m.editToken = ""
switch m.currentTab {
case 0:
m.state = stateFormSite
return m, m.initSiteHuhForm()
case 1:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case 4:
m.state = stateFormMaint
return m, m.initMaintHuhForm()
case 5:
if m.isAdmin {
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
return m, nil
}
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
switch m.currentTab {
case 0:
if len(m.sites) > 0 {
m.editID = m.sites[m.cursor].ID
m.editToken = m.sites[m.cursor].Token
m.state = stateFormSite
return m, m.initSiteHuhForm()
}
case 1:
if len(m.alerts) > 0 {
m.editID = m.alerts[m.cursor].ID
m.state = stateFormAlert
return m, m.initAlertHuhForm()
}
case 5:
if m.isAdmin && len(m.users) > 0 {
m.editID = m.users[m.cursor].ID
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
return m, nil
}
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
switch m.currentTab {
case 0:
if len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name
m.deleteTab = 0
m.state = stateConfirmDelete
}
case 1:
if len(m.alerts) > 0 {
m.deleteID = m.alerts[m.cursor].ID
m.deleteName = m.alerts[m.cursor].Name
m.deleteTab = 1
m.state = stateConfirmDelete
}
case 4:
if len(m.maintenanceWindows) > 0 {
m.deleteID = m.maintenanceWindows[m.cursor].ID
m.deleteName = m.maintenanceWindows[m.cursor].Title
m.deleteTab = 4
m.state = stateConfirmDelete
}
case 5:
if m.isAdmin && len(m.users) > 0 {
m.deleteID = m.users[m.cursor].ID
m.deleteName = m.users[m.cursor].Username
m.deleteTab = 5
m.state = stateConfirmDelete
}
}
return m, nil
}
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
tabCount := 5
if m.isAdmin {
tabCount = 6
}
for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
m.switchTab(i)
return m, nil
}
}
prefix, listLen := m.currentZonePrefix()
end := m.tableOffset + m.maxTableRows
if end > listLen {
end = listLen
}
for i := m.tableOffset; i < end; i++ {
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
m.cursor = i
return m, nil
}
}
return m, nil
}
func (m *Model) switchTab(idx int) {
maxTabs := 4
if m.isAdmin {
maxTabs = 5
}
if idx > maxTabs {
idx = 0
}
m.currentTab = idx
m.cursor = 0
m.tableOffset = 0
switch idx {
case 2:
m.state = stateLogs
case 5:
m.state = stateUsers
default:
m.state = stateDashboard
}
}
func (m *Model) adjustCursor(newLen int) {
if m.cursor >= newLen && m.cursor > 0 {
m.cursor--
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
if m.tableOffset < 0 {
m.tableOffset = 0
}
}
}
func (m *Model) submitForm() tea.Cmd {
switch m.state {
case stateFormSite:
if m.siteFormData != nil {
return m.submitSiteForm()
}
case stateFormAlert:
if m.alertFormData != nil {
return m.submitAlertForm()
}
case stateFormUser:
if m.userFormData != nil {
return m.submitUserForm()
}
case stateFormMaint:
if m.maintFormData != nil {
return m.submitMaintForm()
}
}
return nil
}
func (m Model) currentListLen() int {
switch m.currentTab {
case 1:
return len(m.alerts)
case 3:
return len(m.nodes)
case 4:
return len(m.maintenanceWindows)
case 5:
return len(m.users)
default:
return len(m.sites)
}
}
func (m Model) currentZonePrefix() (string, int) {
switch m.currentTab {
case 0:
return "site", len(m.sites)
case 1:
return "alert", len(m.alerts)
case 4:
return "maint", len(m.maintenanceWindows)
case 5:
return "user", len(m.users)
default:
return "site", 0
}
}