f00acbc280
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.
811 lines
19 KiB
Go
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
|
|
}
|
|
}
|