Files
uptop/internal/tui/update.go
T
lerko 989dd1fb39 feat(tui): fullscreen detail view + enriched sidebar content
On narrow terminals (<120 cols), Enter opens a fullscreen detail view
instead of the sidebar that can't be seen. Wide terminals keep existing
sidebar toggle behavior.

Enrich detail panel with: state since, last success, connection chain
diagnostic, probe results, latency histogram, extended endpoint fields
(interval/timeout/method/codes/regions/description), push token,
maintenance window name. Breadcrumb shown in fullscreen mode only.
2026-07-01 16:12:45 -04:00

1011 lines
25 KiB
Go

package tui
import (
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
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:
detailVisible := m.detailOpen || m.state == stateDetailFullscreen
if detailVisible && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil
}
m.detailChanges = msg.changes
m.detailChangesSiteID = msg.siteID
m.detailDailyDays = msg.dailyDays
return m, nil
case historyDataMsg:
if msg.siteID != m.historySiteID {
return m, nil
}
m.historyChanges = msg.changes
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)
}
if m.state == stateLogs {
return m.handleLogsFullscreen(msg)
}
if m.state == stateDetailFullscreen {
return m.handleDetailFullscreen(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
ctx := m.ctx
id := m.deleteID
var cmd tea.Cmd
switch m.deleteKind {
case "site":
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) })
m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1)
case "maint":
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) })
case "alert":
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) })
case "user":
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) })
}
m.refreshLive()
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
return m, cmd
case "n", "N", "esc":
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "ctrl+c" {
return m, tea.Quit
}
if keyMsg.String() == "esc" {
m.huhForm = nil
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
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.state == stateFormSite && m.siteFormData != nil &&
m.siteFormData.SiteType != m.lastSiteType {
rebuildCmd := m.rebuildSiteForm()
// Advance to Type select — user just changed it.
skipName := m.huhForm.NextField()
return m, tea.Batch(rebuildCmd, skipName)
}
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
}
const logsStripHeight = 6
func (m *Model) recalcLayout() {
chrome := chromeBase
if m.filterMode || m.filterText != "" {
chrome++
}
if m.bottomPanel != bottomNone {
chrome += logsStripHeight
}
m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 {
m.maxTableRows = 1
}
}
func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.termWidth = msg.Width
m.termHeight = msg.Height
m.recalcLayout()
m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2)
if m.huhForm != nil {
formHeight := msg.Height - 7
if formHeight < 5 {
formHeight = 5
}
m.huhForm.WithHeight(formHeight)
}
return m, nil
}
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.detailOpen && m.state != stateDetailFullscreen) || 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
ctx := m.ctx
return func() tea.Msg {
if err := eng.TestAlert(ctx, id); err != nil {
eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err))
}
return nil
}
}
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
m.focusedPanel = panelMonitors
case "ctrl+c":
return m, tea.Quit
case "f":
m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
case "up", "k":
m.logViewport.ScrollUp(1)
case "down", "j":
m.logViewport.ScrollDown(1)
case "pgup":
m.logViewport.ScrollUp(m.logViewport.Height)
case "pgdown":
m.logViewport.ScrollDown(m.logViewport.Height)
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
m.logViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.logViewport.ScrollDown(3)
}
case tickMsg:
m.refreshLogContent()
}
return m, nil
}
func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
m.focusedPanel = panelMonitors
case "ctrl+c":
return m, tea.Quit
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.detailMode = detailHistory
m.detailScrollOffset = 0
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2
m.detailMode = detailSLA
m.detailScrollOffset = 0
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
case "1", "2", "3", "4":
if m.detailMode == detailSLA {
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
m.detailScrollOffset = 0
return m, m.loadSLACmd(m.slaSiteID, idx)
}
}
case "up", "k":
m.detailScrollOffset--
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case "down", "j":
m.detailScrollOffset++
case "pgup":
m.detailScrollOffset -= 10
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case "pgdown":
m.detailScrollOffset += 10
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
m.detailScrollOffset -= 3
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case tea.MouseButtonWheelDown:
m.detailScrollOffset += 3
}
}
return m, nil
}
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.state != stateDashboard {
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.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp {
m.scrollLogs(-3)
} else {
m.scrollLogs(3)
}
return m, nil
}
if m.focusedPanel == panelDetail && m.detailOpen {
if msg.Button == tea.MouseButtonWheelUp {
m.detailScrollOffset -= 3
} else {
m.detailScrollOffset += 3
}
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
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++
}
}
}
m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
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 stateAlertDetail:
return m.handleAlertDetailKey(msg)
case stateSettings:
return m.handleSettingsKey(msg)
case stateMaintDetail:
return m.handleMaintDetailKey(msg)
case stateDashboard:
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.recalcLayout()
m.refreshLive()
case "enter":
m.filterMode = false
m.recalcLayout()
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.Runes) == 1 {
m.filterText += string(msg.Runes)
m.cursor = 0
m.tableOffset = 0
m.refreshLive()
}
}
return m, nil
}
// 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())
return m, nil
}
func (m *Model) handleMaintDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
mw := m.findMaintWindow(m.maintDetailID)
switch msg.String() {
case "q", "esc":
m.state = stateDashboard
m.focusedPanel = panelMaint
case "x":
if mw != nil {
now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive {
st := m.store
ctx := m.ctx
id := mw.ID
m.state = stateDashboard
m.focusedPanel = panelMaint
m.refreshLive()
return m, writeCmd("End maintenance", func() error {
return st.EndMaintenanceWindow(ctx, id)
})
}
}
case "d":
if mw != nil {
m.deleteID = mw.ID
m.deleteName = mw.Title
m.deleteKind = "maint"
m.state = stateConfirmDelete
}
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) findMaintWindow(id int) *models.MaintenanceWindow {
for i := range m.maintenanceWindows {
if m.maintenanceWindows[i].ID == id {
return &m.maintenanceWindows[i]
}
}
return nil
}
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "i", "esc":
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
}
return m, nil
}
func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "S":
m.state = stateDashboard
case "ctrl+c":
return m, tea.Quit
case "left":
m.switchSettingsSection(m.settingsSection - 1)
m.settingsCursor = 0
m.settingsOffset = 0
case "right":
m.switchSettingsSection(m.settingsSection + 1)
m.settingsCursor = 0
m.settingsOffset = 0
case "up", "k":
if m.settingsCursor > 0 {
m.settingsCursor--
if m.settingsCursor < m.settingsOffset {
m.settingsOffset = m.settingsCursor
}
}
case "down", "j":
max := m.settingsListLen() - 1
if m.settingsCursor < max {
m.settingsCursor++
if m.settingsCursor >= m.settingsOffset+m.maxTableRows {
m.settingsOffset++
}
}
case "n":
m.editID = 0
m.editToken = ""
m.returnState = stateSettings
switch m.settingsSection {
case sectionAlerts:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case sectionUsers:
if m.isAdmin {
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
case "e":
m.returnState = stateSettings
switch m.settingsSection {
case sectionAlerts:
if len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) {
m.editID = m.alerts[m.settingsCursor].ID
m.state = stateFormAlert
return m, m.initAlertHuhForm()
}
case sectionUsers:
if m.isAdmin && len(m.users) > 0 && m.settingsCursor < len(m.users) {
m.editID = m.users[m.settingsCursor].ID
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
case "d":
m.returnState = stateSettings
switch m.settingsSection {
case sectionAlerts:
if len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) {
m.deleteID = m.alerts[m.settingsCursor].ID
m.deleteName = m.alerts[m.settingsCursor].Name
m.deleteKind = "alert"
m.state = stateConfirmDelete
}
case sectionUsers:
if m.isAdmin && len(m.users) > 0 && m.settingsCursor < len(m.users) {
m.deleteID = m.users[m.settingsCursor].ID
m.deleteName = m.users[m.settingsCursor].Username
m.deleteKind = "user"
m.state = stateConfirmDelete
}
}
case "t":
if m.settingsSection == sectionAlerts && len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) {
a := m.alerts[m.settingsCursor]
return m, m.testAlertCmd(a.ID, a.Name)
}
case "i":
if m.settingsSection == sectionAlerts && len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) {
m.returnState = stateSettings
m.state = stateAlertDetail
}
}
return m, nil
}
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
return m, tea.Quit
case "/":
m.filterMode = true
m.recalcLayout()
return m, nil
case ">", ".":
m.sortColumn = (m.sortColumn + 1) % sortMax
m.sortAsc = false
m.refreshLive()
case "<", ",":
m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax
m.sortAsc = false
m.refreshLive()
case "r":
m.sortAsc = !m.sortAsc
m.refreshLive()
case "m":
if m.bottomPanel == bottomMaint {
m.bottomPanel = bottomNone
m.focusedPanel = panelMonitors
} else {
m.bottomPanel = bottomMaint
m.focusedPanel = panelMaint
}
m.recalcLayout()
case "l":
if m.bottomPanel == bottomLogs {
m.bottomPanel = bottomNone
m.focusedPanel = panelMonitors
} else {
m.bottomPanel = bottomLogs
m.focusedPanel = panelLogs
}
m.recalcLayout()
case "up", "k":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset--
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
return m, nil
}
if m.focusedPanel == panelMaint {
m.scrollMaintCursor(-1)
return m, nil
}
if m.focusedPanel == panelLogs {
m.scrollLogs(-1)
return m, nil
}
if m.cursor > 0 {
m.cursor--
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "down", "j":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset++
return m, nil
}
if m.focusedPanel == panelMaint {
m.scrollMaintCursor(1)
return m, nil
}
if m.focusedPanel == panelLogs {
m.scrollLogs(1)
return m, nil
}
max := m.currentListLen() - 1
if m.cursor < max {
m.cursor++
if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset++
}
m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "n":
if m.focusedPanel == panelMaint {
m.state = stateFormMaint
return m, m.initMaintHuhForm()
}
return m.handleNewItem()
case "enter":
if m.focusedPanel == panelMaint {
windows := m.activeMaintWindows()
if m.maintCursor < len(windows) {
m.maintDetailID = windows[m.maintCursor].ID
m.state = stateMaintDetail
}
return m, nil
}
if m.focusedPanel == panelLogs {
m.refreshLogContent()
m.logViewport.GotoTop()
m.state = stateLogs
return m, nil
}
if len(m.sites) > 0 {
narrow := m.termWidth < wideBreakpoint
if narrow {
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.state = stateDetailFullscreen
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
m.detailOpen = !m.detailOpen
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.recalcLayout()
st := m.store
ctx := m.ctx
open := m.detailOpen
var cmd tea.Cmd
if m.detailOpen {
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
}
saveCmd := writeCmd("Save detail preference", func() error {
v := "false"
if open {
v = "true"
}
return st.SetPreference(ctx, "detail_open", v)
})
if cmd != nil {
return m, tea.Batch(cmd, saveCmd)
}
return m, saveCmd
}
case "e":
return m.handleEditItem()
case " ":
if 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
ctx := m.ctx
m.refreshLive()
return m, writeCmd("Save collapsed groups", func() error {
return st.SetPreference(ctx, "collapsed_groups", payload)
})
}
case "p":
if len(m.sites) > 0 {
id := m.sites[m.cursor].ID
paused := m.engine.ToggleSitePause(id)
st := m.store
ctx := m.ctx
m.refreshLive()
return m, writeCmd("Update pause state", func() error {
return st.UpdateSitePaused(ctx, id, paused)
})
}
case "esc":
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen && m.detailMode != detailDefault {
m.detailMode = detailDefault
m.detailScrollOffset = 0
} else if m.detailOpen {
m.detailOpen = false
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.recalcLayout()
st := m.store
ctx := m.ctx
return m, writeCmd("Save detail preference", func() error {
return st.SetPreference(ctx, "detail_open", "false")
})
}
case "h":
if m.detailOpen && m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.detailMode = detailHistory
m.detailScrollOffset = 0
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.detailOpen && m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2
m.detailMode = detailSLA
m.detailScrollOffset = 0
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
case "1", "2", "3", "4":
if m.detailOpen && m.detailMode == detailSLA {
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
m.detailScrollOffset = 0
return m, m.loadSLACmd(m.slaSiteID, idx)
}
}
case "x":
if m.focusedPanel == panelMaint {
windows := m.activeMaintWindows()
if m.maintCursor < len(windows) {
mw := windows[m.maintCursor]
now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive {
st := m.store
ctx := m.ctx
id := mw.ID
m.refreshLive()
return m, writeCmd("End maintenance", func() error {
return st.EndMaintenanceWindow(ctx, id)
})
}
}
}
case "S":
m.settingsCursor = 0
m.settingsOffset = 0
m.state = stateSettings
return m, nil
case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex]
m.st = newStyles(m.theme)
st := m.store
ctx := m.ctx
name := m.theme.Name
return m, writeCmd("Save theme", func() error {
return st.SetPreference(ctx, "theme", name)
})
case "d":
if m.focusedPanel == panelMaint {
windows := m.activeMaintWindows()
if m.maintCursor < len(windows) {
mw := windows[m.maintCursor]
m.deleteID = mw.ID
m.deleteName = mw.Title
m.deleteKind = "maint"
m.state = stateConfirmDelete
}
return m, nil
}
return m.handleDeleteItem()
}
return m, nil
}
func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0
m.editToken = ""
m.state = stateFormSite
return m, m.initSiteHuhForm()
}
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
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()
}
return m, nil
}
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
if len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name
m.deleteKind = "site"
m.state = stateConfirmDelete
}
return m, nil
}
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
sortZones := []struct {
zone string
col int
}{
{"sort-status", sortStatus},
{"sort-name", sortName},
{"sort-latency", sortLatency},
}
for _, sz := range sortZones {
if m.zones.Get(sz.zone).InBounds(msg) {
if m.sortColumn == sz.col {
m.sortAsc = !m.sortAsc
} else {
m.sortColumn = sz.col
m.sortAsc = false
}
m.refreshLive()
return m, nil
}
}
if m.bottomPanel == bottomMaint && m.zones.Get("panel-maint").InBounds(msg) {
m.focusedPanel = panelMaint
_, relY := m.zones.Get("panel-maint").Pos(msg)
row := relY - 2
windows := m.activeMaintWindows()
if row >= 0 && row < len(windows) {
m.maintCursor = row
}
return m, nil
} else if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors
_, relY := m.zones.Get("panel-monitors").Pos(msg)
row := relY - 4 + m.tableOffset
if row >= 0 && row < len(m.sites) {
m.cursor = row
m.syncSelectedID()
if m.detailOpen {
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
return m, nil
} else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs
return m, nil
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
m.focusedPanel = panelDetail
return m, nil
}
return m, nil
}
func (m *Model) adjustCursor(_ int) {
m.clampCursor()
}
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 {
return len(m.sites)
}