6cf0efed9b
1. UpdateSite handles token-read Scan error instead of ignoring it. sql.ErrNoRows (nonexistent site) passes through; real DB errors surface. 2. RunCheck allowPrivate changed from variadic to real bool param. Dead maxRequestBody duplicate removed from sqlstore.go. 3. Footer help bar documents [Space] for group collapse. 4. adjustCursor unified with clampCursor — one clamping path instead of two with different semantics. 5. Compose cluster/probe example files annotate hardcoded secrets with "EXAMPLE ONLY — rotate before use". 6. huhForm.WithHeight moved from View() to handleResize — no longer mutates form state during render. 7. maxTableRows recalculated on filter enter/exit via recalcLayout() — was only recalculated on resize, causing off-by-one when the filter bar appeared/disappeared.
812 lines
19 KiB
Go
812 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 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) recalcLayout() {
|
|
chrome := chromeBase
|
|
if m.filterMode || m.filterText != "" {
|
|
chrome++
|
|
}
|
|
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)
|
|
m.historyViewport.Width = msg.Width - chromePadH
|
|
m.historyViewport.Height = msg.Height - 10
|
|
m.slaViewport.Width = msg.Width - chromePadH
|
|
m.slaViewport.Height = msg.Height - 16
|
|
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.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++
|
|
}
|
|
}
|
|
}
|
|
m.syncSelectedID()
|
|
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.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
|
|
}
|
|
|
|
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":
|
|
m.state = stateDashboard
|
|
}
|
|
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)
|
|
|
|
if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
|
|
x, _ := zi.Pos(msg)
|
|
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, 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, detailSparkWidth, 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 "q", "i", "esc":
|
|
m.state = stateDashboard
|
|
}
|
|
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
|
|
m.recalcLayout()
|
|
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
|
|
}
|
|
m.syncSelectedID()
|
|
}
|
|
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++
|
|
}
|
|
m.syncSelectedID()
|
|
}
|
|
}
|
|
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":
|
|
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(_ 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 {
|
|
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
|
|
}
|
|
}
|