feat(tui): mouse click for settings sections, column sort, logs panel
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 51s

- 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
This commit is contained in:
2026-06-28 11:34:34 -04:00
parent ab1194f74d
commit 650e6c4b16
6 changed files with 127 additions and 5 deletions
+18
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
) )
type logSeverity int 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) 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() { func (m *Model) refreshLogContent() {
var rendered []string var rendered []string
total := 0 total := 0
+6 -2
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"fmt"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -18,11 +20,13 @@ func (m Model) viewSettingsTab() string {
var tabs []string var tabs []string
for i, name := range sections { for i, name := range sections {
var rendered string
if i == m.settingsSection { if i == m.settingsSection {
tabs = append(tabs, m.st.activeTab.Render(name)) rendered = m.st.activeTab.Render(name)
} else { } 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...) header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
+10
View File
@@ -110,6 +110,11 @@ func (m Model) computeLayout() tableLayout {
sortName: colName, sortName: colName,
sortLatency: colLatency, sortLatency: colLatency,
} }
sortableKeys := map[colKey]string{
colStatus: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
if sortedKey, ok := sortColMap[m.sortColumn]; ok { if sortedKey, ok := sortColMap[m.sortColumn]; ok {
arrow := "▼" arrow := "▼"
if m.sortAsc { 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) numCols := len(headers)
borderOverhead := 2 + (numCols - 1) borderOverhead := 2 + (numCols - 1)
+2
View File
@@ -197,6 +197,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load tabSeq int // seq of the newest issued tab-data load
logsOpen bool
detailOpen bool detailOpen bool
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
@@ -247,6 +248,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
logsOpen: true,
detailOpen: detailPref == "true", detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
+87 -1
View File
@@ -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 { if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
return m.handleFormMsg(msg) return m.handleFormMsg(msg)
} }
if m.state == stateLogs {
return m.handleLogsFullscreen(msg)
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseMsg: 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) { func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.state == stateHistory { if m.state == stateHistory {
switch msg.Button { switch msg.Button {
@@ -579,10 +616,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.switchSettingsSection(m.settingsSection + 1) m.switchSettingsSection(m.settingsSection + 1)
case tabMonitors: case tabMonitors:
if m.focusedPanel == panelLogs { if m.focusedPanel == panelLogs {
m.logsOpen = false
m.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
} else { } else {
m.logsOpen = true
m.focusedPanel = panelLogs m.focusedPanel = panelLogs
} }
m.recalcLayout()
} }
case "up", "k": case "up", "k":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
@@ -618,6 +658,12 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "n": case "n":
return m.handleNewItem() return m.handleNewItem()
case "enter": 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 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID) return m, m.loadDetailCmd(m.sites[m.cursor].ID)
@@ -840,11 +886,51 @@ 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 { 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) { if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
} else if m.zones.Get("panel-logs").InBounds(msg) { } else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs if m.focusedPanel == panelLogs {
m.logsOpen = false
m.focusedPanel = panelMonitors
m.recalcLayout()
} else {
m.focusedPanel = panelLogs
}
return m, nil return m, nil
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) { } else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
m.focusedPanel = panelDetail m.focusedPanel = panelDetail
+4 -2
View File
@@ -92,6 +92,8 @@ func (m Model) View() string {
return "" return ""
case stateDetail: case stateDetail:
return m.zones.Scan(m.viewDetailPanel()) return m.zones.Scan(m.viewDetailPanel())
case stateLogs:
return m.viewLogsFullscreen()
case stateHistory: case stateHistory:
return m.viewHistoryPanel() return m.viewHistoryPanel()
case stateSLA: case stateSLA:
@@ -152,7 +154,7 @@ func (m Model) viewDashboard() string {
var content string var content string
switch m.currentTab { switch m.currentTab {
case tabMonitors: case tabMonitors:
showSidebar := m.termWidth >= wideBreakpoint showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen
if showSidebar { if showSidebar {
availW := m.termWidth - chromePadH availW := m.termWidth - chromePadH
leftW := availW * 70 / 100 leftW := availW * 70 / 100
@@ -310,7 +312,7 @@ func (m Model) renderFooter(stats dashboardStats) string {
switch m.currentTab { switch m.currentTab {
case tabMonitors: case tabMonitors:
if m.focusedPanel == panelLogs { 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 { } else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else { } else {