989dd1fb39
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.
1011 lines
25 KiB
Go
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)
|
|
}
|