From 047bb237e008713a641c0ed7b7ee7f91a433cabf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 17:59:47 -0400 Subject: [PATCH] feat(tui): consolidate 6 tabs to 3, add log sidebar Tab bar: Monitors | Maint | Settings (was 6 tabs). Settings tab merges Alerts, Nodes, Users as sub-sections with left/right arrow navigation. Each section keeps its own cursor, keybindings, and CRUD operations. Monitors tab now shows a log sidebar at >= 120 cols (70/30 split). Under 120 cols, monitors render full-width without logs. - Introduced tab constants (tabMonitors, tabMaint, tabSettings) - Introduced section constants (sectionAlerts, sectionNodes, sectionUsers) - Removed stateLogs and stateUsers states - All magic tab numbers replaced with named constants --- internal/tui/data.go | 4 +- internal/tui/tab_settings.go | 58 ++++++++ internal/tui/tab_users.go | 2 +- internal/tui/tui.go | 33 +++-- internal/tui/update.go | 237 +++++++++++++++------------------ internal/tui/view_dashboard.go | 61 +++++---- 6 files changed, 224 insertions(+), 171 deletions(-) create mode 100644 internal/tui/tab_settings.go diff --git a/internal/tui/data.go b/internal/tui/data.go index a04ff25..03c9532 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -106,7 +106,7 @@ func (m *Model) refreshLive() { m.sites = ordered m.refreshLogContent() - if m.currentTab == 0 && m.selectedID != 0 { + if m.currentTab == tabMonitors && m.selectedID != 0 { for i, s := range m.sites { if s.ID == m.selectedID { m.cursor = i @@ -118,7 +118,7 @@ func (m *Model) refreshLive() { } func (m *Model) syncSelectedID() { - if m.currentTab == 0 && m.cursor < len(m.sites) { + if m.currentTab == tabMonitors && m.cursor < len(m.sites) { m.selectedID = m.sites[m.cursor].ID } } diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go new file mode 100644 index 0000000..c1d2f51 --- /dev/null +++ b/internal/tui/tab_settings.go @@ -0,0 +1,58 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewSettingsTab() string { + maxSections := 2 + if m.isAdmin { + maxSections = 3 + } + + sections := []string{"Alerts", "Nodes"} + if m.isAdmin { + sections = append(sections, "Users") + } + _ = maxSections + + var tabs []string + for i, name := range sections { + if i == m.settingsSection { + tabs = append(tabs, m.st.activeTab.Render(name)) + } else { + tabs = append(tabs, m.st.inactiveTab.Render(name)) + } + } + header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + + var content string + switch m.settingsSection { + case sectionAlerts: + content = m.viewAlertsTab() + case sectionNodes: + content = m.viewNodesTab() + case sectionUsers: + if m.isAdmin { + content = m.viewUsersTab() + } + } + + return header + "\n" + content +} + +func (m *Model) switchSettingsSection(idx int) { + max := 1 + if m.isAdmin { + max = 2 + } + if idx > max { + idx = 0 + } + if idx < 0 { + idx = max + } + m.settingsSection = idx + m.cursor = 0 + m.tableOffset = 0 +} diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 3666357..2bd86fa 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd { st := m.store id := m.editID username, key, role := d.Username, d.PublicKey, d.Role - m.state = stateUsers + m.state = stateDashboard if id > 0 { return writeCmd("Update user", func() error { return st.UpdateUser(context.Background(), id, username, key, role) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 59bd582..602c2b1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -84,6 +84,18 @@ const ( detailSparkWidth = 40 ) +const ( + tabMonitors = 0 + tabMaint = 1 + tabSettings = 2 +) + +const ( + sectionAlerts = 0 + sectionNodes = 1 + sectionUsers = 2 +) + type sessionState int const ( @@ -102,16 +114,17 @@ const ( ) type Model struct { - state sessionState - currentTab int - cursor int - selectedID int - tableOffset int - maxTableRows int - termWidth int - termHeight int - editID int - editToken string + state sessionState + currentTab int + settingsSection int + cursor int + selectedID int + tableOffset int + maxTableRows int + termWidth int + termHeight int + editID int + editToken string huhForm *huh.Form siteFormData *siteFormData diff --git a/internal/tui/update.go b/internal/tui/update.go index e86582b..5800bf0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { id := m.deleteID var cmd tea.Cmd switch m.deleteTab { - case 0: + case tabMonitors: 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: + case tabMaint: 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) + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) }) + m.adjustCursor(len(m.alerts) - 1) + case sectionUsers: + 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 } @@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg.String() == "esc" { m.huhForm = nil m.state = stateDashboard - if m.currentTab == 5 { - m.state = stateUsers - } return m, nil } } @@ -265,7 +259,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } - if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { + if m.state != stateDashboard { return m, nil } if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { @@ -275,15 +269,6 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { 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 { @@ -325,7 +310,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSLAKey(msg) case stateAlertDetail: return m.handleAlertDetailKey(msg) - case stateDashboard, stateLogs, stateUsers: + case stateDashboard: return m.handleDashboardKey(msg) } return m, nil @@ -521,33 +506,27 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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 { + if m.currentTab == tabMonitors { m.filterMode = true m.recalcLayout() return m, nil } - case "f": - if m.state == stateLogs { - m.logFilterImportant = !m.logFilterImportant - m.refreshLogContent() - 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 "left", "h": + if m.currentTab == tabSettings { + m.switchSettingsSection(m.settingsSection - 1) + } + case "right", "l": + if m.currentTab == tabSettings { + m.switchSettingsSection(m.settingsSection + 1) } case "up", "k": - if m.state == stateLogs { - m.logViewport.ScrollUp(1) - } else if m.cursor > 0 { + if m.cursor > 0 { m.cursor-- if m.cursor < m.tableOffset { m.tableOffset = m.cursor @@ -555,29 +534,25 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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() + 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 { + if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && 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" { + if m.currentTab == tabMonitors && 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) @@ -588,7 +563,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "p": - if m.currentTab == 0 && len(m.sites) > 0 { + if m.currentTab == tabMonitors && len(m.sites) > 0 { id := m.sites[m.cursor].ID paused := m.engine.ToggleSitePause(id) st := m.store @@ -598,14 +573,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "i": - if m.currentTab == 0 && len(m.sites) > 0 { + if m.currentTab == tabMonitors && 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 { + } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } case "x": - if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { + if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { mw := m.maintenanceWindows[m.cursor] now := time.Now() isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) @@ -637,19 +612,22 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { m.editID = 0 m.editToken = "" switch m.currentTab { - case 0: + case tabMonitors: m.state = stateFormSite return m, m.initSiteHuhForm() - case 1: - m.state = stateFormAlert - return m, m.initAlertHuhForm() - case 4: + case tabMaint: m.state = stateFormMaint return m, m.initMaintHuhForm() - case 5: - if m.isAdmin { - m.state = stateFormUser - return m, m.initUserHuhForm() + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + m.state = stateFormAlert + return m, m.initAlertHuhForm() + case sectionUsers: + if m.isAdmin { + m.state = stateFormUser + return m, m.initUserHuhForm() + } } } return m, nil @@ -657,24 +635,27 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { switch m.currentTab { - case 0: + case tabMonitors: 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() + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 { + m.editID = m.alerts[m.cursor].ID + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 { + m.editID = m.users[m.cursor].ID + m.state = stateFormUser + return m, m.initUserHuhForm() + } } } return m, nil @@ -682,43 +663,43 @@ func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { switch m.currentTab { - case 0: + case tabMonitors: if len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID m.deleteName = m.sites[m.cursor].Name - m.deleteTab = 0 + m.deleteTab = tabMonitors 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: + case tabMaint: if len(m.maintenanceWindows) > 0 { m.deleteID = m.maintenanceWindows[m.cursor].ID m.deleteName = m.maintenanceWindows[m.cursor].Title - m.deleteTab = 4 + m.deleteTab = tabMaint 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 + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 { + m.deleteID = m.alerts[m.cursor].ID + m.deleteName = m.alerts[m.cursor].Name + m.deleteTab = tabSettings + m.state = stateConfirmDelete + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 { + m.deleteID = m.users[m.cursor].ID + m.deleteName = m.users[m.cursor].Username + m.deleteTab = tabSettings + m.state = stateConfirmDelete + } } } return m, nil } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := 5 - if m.isAdmin { - tabCount = 6 - } + tabCount := tabSettings + 1 for i := 0; i < tabCount; i++ { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { m.switchTab(i) @@ -743,24 +724,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m *Model) switchTab(idx int) { - maxTabs := 4 - if m.isAdmin { - maxTabs = 5 - } + maxTabs := tabSettings 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 - } + m.state = stateDashboard } func (m *Model) adjustCursor(_ int) { @@ -791,30 +762,36 @@ func (m *Model) submitForm() tea.Cmd { 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: + case tabMonitors: return len(m.sites) + case tabMaint: + return len(m.maintenanceWindows) + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + return len(m.alerts) + case sectionNodes: + return len(m.nodes) + case sectionUsers: + return len(m.users) + } } + return 0 } func (m Model) currentZonePrefix() (string, int) { switch m.currentTab { - case 0: + case tabMonitors: return "site", len(m.sites) - case 1: - return "alert", len(m.alerts) - case 4: + case tabMaint: return "maint", len(m.maintenanceWindows) - case 5: - return "user", len(m.users) - default: - return "site", 0 + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + return "alert", len(m.alerts) + case sectionUsers: + return "user", len(m.users) + } } + return "site", 0 } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 02bf716..94374f5 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -151,20 +151,22 @@ func (m Model) viewDashboard() string { var content string switch m.currentTab { - case 0: - content = m.viewSitesTab() - case 1: - content = m.viewAlertsTab() - case 2: - content = m.viewLogsTab() - case 3: - content = m.viewNodesTab() - case 4: - content = m.viewMaintTab() - case 5: - if m.isAdmin { - content = m.viewUsersTab() + case tabMonitors: + monitors := m.viewSitesTab() + if m.termWidth >= wideBreakpoint { + availW := m.termWidth - chromePadH + leftW := availW * 70 / 100 + rightW := availW - leftW + left := lipgloss.NewStyle().Width(leftW).Render(monitors) + right := lipgloss.NewStyle().Width(rightW).Render(m.viewLogsTab()) + content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) + } else { + content = monitors } + case tabMaint: + content = m.viewMaintTab() + case tabSettings: + content = m.viewSettingsTab() } content = strings.TrimSpace(content) @@ -199,15 +201,15 @@ type tabEntry struct { } func (m Model) renderTabBar(stats dashboardStats) string { + settingsCount := len(m.alerts) + len(m.nodes) + settingsWarn := stats.offlineNodes + if m.isAdmin { + settingsCount += len(m.users) + } tabs := []tabEntry{ {"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount}, - {"Alerts", len(m.alerts), 0}, - {"Logs", 0, 0}, - {"Nodes", len(m.nodes), stats.offlineNodes}, {"Maint", len(m.maintenanceWindows), stats.activeMaint}, - } - if m.isAdmin { - tabs = append(tabs, tabEntry{"Users", len(m.users), 0}) + {"Settings", settingsCount, settingsWarn}, } countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted) @@ -270,23 +272,26 @@ func (m Model) renderFooter(stats dashboardStats) string { var keys string switch m.currentTab { - case 0: + case tabMonitors: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" - case 1: - keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" - case 2: - keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit" - case 4: + case tabMaint: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" - case 5: - keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit" + case sectionUsers: + keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit" + default: + keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit" + } default: keys = "[T]Theme [Tab]Switch [q]Quit" } ver := m.st.subtleStyle.Render("v" + m.version) line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver - if m.filterText != "" && m.currentTab == 0 { + if m.filterText != "" && m.currentTab == tabMonitors { line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver }