From 18e2d60210c8bf7154e42706fef9fe5e82906fa1 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 28 Jun 2026 11:34:34 -0400 Subject: [PATCH] feat(tui): mouse click for settings sections, column sort, logs panel - Click Alerts/Nodes/Users sub-tabs in settings to switch sections - Click STATUS/NAME/LATENCY column headers to sort (click again to reverse direction) - Logs panel show/hide: [l] toggles sidebar visibility, click focused panel to hide. Monitors expand to full width when hidden. - Logs full-screen: Enter when focused on logs opens scrollable full-screen log view with severity tags, filter toggle [f], and live updates --- internal/tui/tab_logs.go | 18 ++++++++ internal/tui/tab_settings.go | 8 +++- internal/tui/tab_sites.go | 10 +++++ internal/tui/tui.go | 2 + internal/tui/update.go | 80 ++++++++++++++++++++++++++++++++++ internal/tui/view_dashboard.go | 6 ++- 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 4d1ed8e..d411530 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -5,6 +5,7 @@ import ( "strings" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "github.com/charmbracelet/lipgloss" ) type logSeverity int @@ -72,6 +73,23 @@ func (m Model) renderLogLine(entry models.LogEntry) string { return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message) } +func (m Model) viewLogsFullscreen() string { + header := m.st.subtleStyle.Render(" Logs") + "\n" + m.divider() + + filterLabel := m.st.subtleStyle.Render("[f] All") + if m.logFilterImportant { + filterLabel = m.st.subtleStyle.Render("[f] Important only") + } + countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal)) + footer := m.divider() + "\n " + m.st.subtleStyle.Render("[Esc] Back") + " " + filterLabel + " " + countLabel + + m.logViewport.Width = m.termWidth - chromePadH + m.logViewport.Height = m.termHeight - 8 + + return lipgloss.NewStyle().Padding(1, 2).Render( + header + "\n" + m.logViewport.View() + "\n" + footer) +} + func (m *Model) refreshLogContent() { var rendered []string total := 0 diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go index c1d2f51..2b02929 100644 --- a/internal/tui/tab_settings.go +++ b/internal/tui/tab_settings.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + "github.com/charmbracelet/lipgloss" ) @@ -18,11 +20,13 @@ func (m Model) viewSettingsTab() string { var tabs []string for i, name := range sections { + var rendered string if i == m.settingsSection { - tabs = append(tabs, m.st.activeTab.Render(name)) + rendered = m.st.activeTab.Render(name) } else { - tabs = append(tabs, m.st.inactiveTab.Render(name)) + rendered = m.st.inactiveTab.Render(name) } + tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered)) } header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 8a65d29..45464a0 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -110,6 +110,11 @@ func (m Model) computeLayout() tableLayout { sortName: colName, sortLatency: colLatency, } + sortableKeys := map[colKey]string{ + colStatus: "sort-status", + colName: "sort-name", + colLatency: "sort-latency", + } if sortedKey, ok := sortColMap[m.sortColumn]; ok { arrow := "▼" if m.sortAsc { @@ -122,6 +127,11 @@ func (m Model) computeLayout() tableLayout { } } } + for i, k := range active { + if zoneID, ok := sortableKeys[k]; ok { + headers[i] = m.zones.Mark(zoneID, headers[i]) + } + } numCols := len(headers) borderOverhead := 2 + (numCols - 1) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 81b1d48..61e25c9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -197,6 +197,7 @@ type Model struct { lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) tabSeq int // seq of the newest issued tab-data load + logsOpen bool detailOpen bool detailChanges []models.StateChange detailChangesSiteID int @@ -247,6 +248,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor theme: theme, themeIndex: themeIdx, st: newStyles(theme), + logsOpen: true, detailOpen: detailPref == "true", demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, diff --git a/internal/tui/update.go b/internal/tui/update.go index be85437..ebea0d7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -53,6 +53,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -241,6 +244,40 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd { } } +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 = panelLogs + 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 == stateHistory { switch msg.Button { @@ -579,10 +616,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.switchSettingsSection(m.settingsSection + 1) case tabMonitors: if m.focusedPanel == panelLogs { + m.logsOpen = false m.focusedPanel = panelMonitors } else { + m.logsOpen = true m.focusedPanel = panelLogs } + m.recalcLayout() } case "up", "k": if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { @@ -618,6 +658,12 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "n": return m.handleNewItem() case "enter": + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + m.refreshLogContent() + m.logViewport.GotoTop() + m.state = stateLogs + return m, nil + } if m.currentTab == tabMonitors && len(m.sites) > 0 { m.state = stateDetail return m, m.loadDetailCmd(m.sites[m.cursor].ID) @@ -840,7 +886,41 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } + if m.currentTab == tabSettings { + maxSec := 1 + if m.isAdmin { + maxSec = 2 + } + for i := 0; i <= maxSec; i++ { + if m.zones.Get(fmt.Sprintf("section-%d", i)).InBounds(msg) { + m.switchSettingsSection(i) + return m, nil + } + } + } + if m.currentTab == tabMonitors { + 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.zones.Get("panel-monitors").InBounds(msg) { m.focusedPanel = panelMonitors } else if m.zones.Get("panel-logs").InBounds(msg) { diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index f43c9d4..60bcb9a 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -92,6 +92,8 @@ func (m Model) View() string { return "" case stateDetail: return m.zones.Scan(m.viewDetailPanel()) + case stateLogs: + return m.viewLogsFullscreen() case stateHistory: return m.viewHistoryPanel() case stateSLA: @@ -152,7 +154,7 @@ func (m Model) viewDashboard() string { var content string switch m.currentTab { case tabMonitors: - showSidebar := m.termWidth >= wideBreakpoint + showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen if showSidebar { availW := m.termWidth - chromePadH leftW := availW * 70 / 100 @@ -310,7 +312,7 @@ func (m Model) renderFooter(stats dashboardStats) string { switch m.currentTab { case tabMonitors: if m.focusedPanel == panelLogs { - keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit" + keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit" } else if m.detailOpen { keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" } else { -- 2.52.0