Files
uptop/internal/tui/update.go
T
lerko 631f07c242
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
feat(tui): stable detail panel height with scroll support
Detail sidebar now maintains consistent height matching the monitors
panel across all modes (default, SLA, history). Content scrolls within
the fixed frame via mouse wheel or j/k when the detail panel is
focused. Scroll offset resets on mode/monitor/period changes. Logs
strip stays visible in all states.
2026-06-30 20:36:25 -04:00

933 lines
23 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:
if m.detailOpen && 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)
}
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.logsOpen {
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.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) 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.termWidth >= wideBreakpoint {
if m.focusedPanel == panelMaint {
m.maintOpen = false
m.focusedPanel = panelMonitors
} else {
m.maintOpen = true
m.focusedPanel = panelMaint
}
m.recalcLayout()
}
case "l":
if m.logsOpen {
m.logsOpen = false
m.focusedPanel = panelMonitors
} else {
m.logsOpen = true
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 {
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.maintOpen && m.zones.Get("panel-maint").InBounds(msg) {
m.focusedPanel = panelMaint
return m, nil
} else if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors
} 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
}
end := m.tableOffset + m.maxTableRows
if end > len(m.sites) {
end = len(m.sites)
}
for i := m.tableOffset; i < end; i++ {
if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) {
m.cursor = i
m.syncSelectedID()
m.focusedPanel = panelMonitors
if m.detailOpen {
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
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)
}