feat(tui): enter opens detail sidebar, logs as bottom strip

Enter on a site now opens the detail sidebar instead of a
full-page takeover. Enter on a group expands/collapses children
(same as space). Logs panel moves to a toggleable bottom strip
via the l key with j/k scrolling when focused.
This commit is contained in:
2026-06-30 09:12:13 -04:00
parent 8581662237
commit 3e02833df4
3 changed files with 111 additions and 10 deletions
+68
View File
@@ -2,8 +2,76 @@ package tui
import ( import (
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
) )
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
sev := classifyLog(entry.Message)
var tag string
switch sev {
case severityDown:
tag = m.st.dangerStyle.Render("▼")
case severityUp:
tag = m.st.specialStyle.Render("▲")
case severityWarn:
tag = m.st.warnStyle.Render("◆")
case severitySystem:
tag = m.st.titleStyle.Render("●")
default:
tag = m.st.subtleStyle.Render("·")
}
ts := entry.CreatedAt.Local().Format("01/02 15:04")
msg := entry.Message
msg = strings.TrimPrefix(msg, "Monitor ")
msg = strings.TrimPrefix(msg, "Push ")
prefixW := len(ts) + 4
msgW := maxW - prefixW
if msgW < 5 {
msgW = 5
}
msg = limitStr(msg, msgW)
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
func (m Model) viewLogsStrip(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet")
}
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
var all []string
for _, entry := range logs {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue
}
all = append(all, m.renderCompactLogLine(entry, width))
}
start := m.logScrollOffset
if start > len(all) {
start = len(all)
}
end := start + maxLines
if end > len(all) {
end = len(all)
}
visible := all[start:end]
return style.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) { func (m *Model) scrollLogs(delta int) {
logs := m.engine.GetLogs() logs := m.engine.GetLogs()
total := 0 total := 0
+30 -6
View File
@@ -155,11 +155,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
const logsStripHeight = 6
func (m *Model) recalcLayout() { func (m *Model) recalcLayout() {
chrome := chromeBase chrome := chromeBase
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
if m.logsOpen {
chrome += logsStripHeight
}
m.maxTableRows = m.termHeight - chrome m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
@@ -751,10 +756,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.recalcLayout() m.recalcLayout()
} }
case "l": case "l":
m.refreshLogContent() if m.logsOpen {
m.logViewport.GotoTop() m.logsOpen = false
m.state = stateLogs m.focusedPanel = panelMonitors
return m, nil } else {
m.logsOpen = true
m.focusedPanel = panelLogs
}
m.recalcLayout()
case "up", "k": case "up", "k":
if m.focusedPanel == panelMaint { if m.focusedPanel == panelMaint {
m.scrollMaintCursor(-1) m.scrollMaintCursor(-1)
@@ -818,8 +827,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.state = stateDetail site := m.sites[m.cursor]
return m, m.loadDetailCmd(m.sites[m.cursor].ID) if site.Type == "group" {
m.collapsed[site.ID] = !m.collapsed[site.ID]
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)
})
}
if !m.detailOpen {
m.detailOpen = true
m.detailMode = detailDefault
m.recalcLayout()
}
return m, m.loadDetailCmd(site.ID)
} }
case "e": case "e":
return m.handleEditItem() return m.handleEditItem()
+13 -4
View File
@@ -201,7 +201,14 @@ func (m Model) viewMonitorsLayout() string {
topParts = append(topParts, detailPanel) topParts = append(topParts, detailPanel)
} }
return lipgloss.JoinHorizontal(lipgloss.Top, topParts...) top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
if m.logsOpen {
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
return top + "\n" + logPanel
}
return top
} }
func (m Model) viewDashboard() string { func (m Model) viewDashboard() string {
@@ -285,12 +292,14 @@ func (m Model) renderFooter(_ dashboardStats) string {
var keys string var keys string
if m.focusedPanel == panelMaint { if m.focusedPanel == panelMaint {
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit" keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
} else if m.focusedPanel == panelLogs {
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit"
} else if m.detailOpen && m.detailMode == detailSLA { } else if m.detailOpen && m.detailMode == detailSLA {
keys = "[1-4]Period [Esc]Back [S]Settings [T]Theme [q]Quit" keys = "[1-4]Period [Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
} else if m.detailOpen && m.detailMode == detailHistory { } else if m.detailOpen && m.detailMode == detailHistory {
keys = "[Esc]Back [S]Settings [T]Theme [q]Quit" keys = "[Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
} else if m.detailOpen { } else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" keys = "[i]Close [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
} else { } else {
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
} }