From 66b0681a7671a181a8ffd1cbecbcb1bda97f95d7 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 18:58:49 -0400 Subject: [PATCH 01/10] feat(tui): inline detail panel below monitors table Press i to toggle a compact detail panel below the monitors+logs split. Shows status, latency, uptime, state changes, sparkline, and key hints in ~6 lines. Auto-updates when cursor moves between monitors. h/s/e keys work from the inline detail for history, SLA, and edit. Escape closes the panel. No more full-screen detail takeover for the common case. The old stateDetail path remains for h/s sub-views which still go full-screen. --- internal/tui/tui.go | 2 +- internal/tui/update.go | 36 ++++++++- internal/tui/view_dashboard.go | 16 +++- internal/tui/view_detail_inline.go | 123 +++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 internal/tui/view_detail_inline.go diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7cb209e..dd97daf 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -179,7 +179,7 @@ type Model struct { lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) tabSeq int // seq of the newest issued tab-data load - // detail-panel state-change history, loaded on enter so View does no DB IO + detailOpen bool detailChanges []models.StateChange detailChangesSiteID int diff --git a/internal/tui/update.go b/internal/tui/update.go index 5800bf0..a0b00cb 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -517,7 +517,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "tab": m.switchTab(m.currentTab + 1) - case "left", "h": + case "left": if m.currentTab == tabSettings { m.switchSettingsSection(m.settingsSection - 1) } @@ -532,6 +532,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset = m.cursor } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } case "down", "j": max := m.currentListLen() - 1 @@ -541,6 +544,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset++ } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } case "n": return m.handleNewItem() @@ -574,11 +580,35 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "i": if m.currentTab == tabMonitors && len(m.sites) > 0 { - m.state = stateDetail - return m, m.loadDetailCmd(m.sites[m.cursor].ID) + m.detailOpen = !m.detailOpen + if m.detailOpen { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } + case "esc": + if m.currentTab == tabMonitors && m.detailOpen { + m.detailOpen = false + } + case "h": + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + site := m.sites[m.cursor] + m.historySiteName = site.Name + m.historySiteID = site.ID + m.historyChanges = nil + m.historyViewport = viewport.New( + m.termWidth-chromePadH, + m.termHeight-10, + ) + m.historyViewport.SetContent("\n Loading state history...") + m.state = stateHistory + return m, m.loadHistoryCmd(site.ID) + } + case "s": + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.openSLAView(m.sites[m.cursor]) + } case "x": if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { mw := m.maintenanceWindows[m.cursor] diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 9f67cd0..10a13fe 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -162,10 +162,22 @@ func (m Model) viewDashboard() string { left := lipgloss.NewStyle().Width(leftW).Render(monitors) sidebar := m.viewLogsSidebar(rightW) right := lipgloss.NewStyle().Width(rightW).Render(sidebar) - content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) + top := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + if m.detailOpen { + detail := m.viewDetailInline(availW) + content = top + "\n" + detail + } else { + content = top + } } else { m.contentWidth = m.termWidth - content = m.viewSitesTab() + monitors := m.viewSitesTab() + if m.detailOpen { + detail := m.viewDetailInline(m.termWidth - chromePadH) + content = monitors + "\n" + detail + } else { + content = monitors + } } case tabMaint: m.contentWidth = m.termWidth diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go new file mode 100644 index 0000000..c28ea29 --- /dev/null +++ b/internal/tui/view_detail_inline.go @@ -0,0 +1,123 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewDetailInline(width int) string { + if m.cursor >= len(m.sites) { + return "" + } + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + + var b strings.Builder + + title := m.st.titleStyle.Render(site.Name) + b.WriteString(" " + title + "\n") + + divW := width - 4 + if divW < 20 { + divW = 20 + } + b.WriteString(" " + m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n") + + status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) + latency := m.fmtLatency(site.Latency) + uptime := m.fmtUptime(hist.Statuses) + + line1Parts := []string{status} + if site.Latency > 0 { + line1Parts = append(line1Parts, latency) + } + line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime)) + if !site.LastCheck.IsZero() { + line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck))) + } + b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n") + + if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || + site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { + errW := width - 12 + if errW < 20 { + errW = 20 + } + errMsg := limitStr(site.LastError, errW) + b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n") + } + + var stateChanges []models.StateChange + if m.detailChangesSiteID == site.ID { + stateChanges = m.detailChanges + } + if len(stateChanges) > 0 { + var parts []string + limit := 3 + if len(stateChanges) < limit { + limit = len(stateChanges) + } + for _, sc := range stateChanges[:limit] { + ago := fmtDuration(time.Since(sc.ChangedAt)) + arrow := m.st.subtleStyle.Render("→") + from := m.fmtStatusWord(sc.FromStatus) + to := m.fmtStatusWord(sc.ToStatus) + entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago") + if sc.ErrorReason != "" { + entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30)) + } + parts = append(parts, entry) + } + b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n") + } + + if len(hist.Latencies) > 0 { + sparkW := width - 30 + if sparkW < 10 { + sparkW = 10 + } + if sparkW > detailSparkWidth { + sparkW = detailSparkWidth + } + spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg) + minMs := hist.Latencies[0].Milliseconds() + maxMs := hist.Latencies[0].Milliseconds() + var sumMs int64 + for _, l := range hist.Latencies { + ms := l.Milliseconds() + if ms < minMs { + minMs = ms + } + if ms > maxMs { + maxMs = ms + } + sumMs += ms + } + avgMs := sumMs / int64(len(hist.Latencies)) + stats := fmt.Sprintf("Min %s Avg %s Max %s", + m.fmtLatency(time.Duration(minMs)*time.Millisecond), + m.fmtLatency(time.Duration(avgMs)*time.Millisecond), + m.fmtLatency(time.Duration(maxMs)*time.Millisecond)) + b.WriteString(" " + spark + " " + stats + "\n") + } + + keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close") + b.WriteString(" " + keys + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) +} + +func (m Model) fmtStatusWord(status string) string { + switch status { + case "DOWN": + return m.st.dangerStyle.Render("DOWN") + case "UP": + return m.st.specialStyle.Render("UP") + default: + return m.st.subtleStyle.Render(status) + } +} -- 2.52.0 From c9bd9a5a2e460fbfeb80c79ff8f9ca3b5661760f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:02:18 -0400 Subject: [PATCH 02/10] fix(tui): shrink table rows when detail panel is open --- internal/tui/update.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/tui/update.go b/internal/tui/update.go index a0b00cb..469e26e 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -142,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +const detailInlineHeight = 8 + func (m *Model) recalcLayout() { chrome := chromeBase if m.filterMode || m.filterText != "" { chrome++ } + if m.detailOpen && m.currentTab == tabMonitors { + chrome += detailInlineHeight + } m.maxTableRows = m.termHeight - chrome if m.maxTableRows < 1 { m.maxTableRows = 1 @@ -581,6 +586,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "i": if m.currentTab == tabMonitors && len(m.sites) > 0 { m.detailOpen = !m.detailOpen + m.recalcLayout() if m.detailOpen { return m, m.loadDetailCmd(m.sites[m.cursor].ID) } @@ -590,6 +596,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": if m.currentTab == tabMonitors && m.detailOpen { m.detailOpen = false + m.recalcLayout() } case "h": if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { -- 2.52.0 From 54299583d65d327b215ce3145f50c8b7d9feab3b Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:08:21 -0400 Subject: [PATCH 03/10] debug: make detail title visible with danger style --- internal/tui/view_detail_inline.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index c28ea29..49d1ac4 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -18,8 +18,8 @@ func (m Model) viewDetailInline(width int) string { var b strings.Builder - title := m.st.titleStyle.Render(site.Name) - b.WriteString(" " + title + "\n") + title := m.st.dangerStyle.Render("▶ " + site.Name) + b.WriteString(title + "\n") divW := width - 4 if divW < 20 { -- 2.52.0 From 5720fabdbc359ae0402413cd9097d0b69eb17623 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:13:37 -0400 Subject: [PATCH 04/10] fix(tui): limit sidebar height to match table, fix detail clipping Log sidebar was rendering all lines regardless of table height. When detail panel was open and table shrank, the sidebar stayed tall, pushing the detail panel past MaxHeight (clipped to empty). Now sidebar accepts a maxLines parameter capped to table row count. --- internal/tui/tab_logs_sidebar.go | 5 ++++- internal/tui/view_dashboard.go | 2 +- internal/tui/view_detail_inline.go | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index e21c6cd..ec012b3 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -52,7 +52,7 @@ func (m Model) renderCompactLogLine(line string, maxW int) string { return " " + tag + " " + msg } -func (m Model) viewLogsSidebar(width int) string { +func (m Model) viewLogsSidebar(width, maxLines int) string { logs := m.engine.GetLogs() if len(logs) == 0 { return m.st.subtleStyle.Render(" No logs yet") @@ -69,6 +69,9 @@ func (m Model) viewLogsSidebar(width int) string { continue } lines = append(lines, m.renderCompactLogLine(line, width)) + if maxLines > 0 && len(lines) >= maxLines { + break + } } return "\n" + sidebarStyle.Render(strings.Join(lines, "\n")) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 10a13fe..acf488a 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -160,7 +160,7 @@ func (m Model) viewDashboard() string { m.contentWidth = leftW monitors := m.viewSitesTab() left := lipgloss.NewStyle().Width(leftW).Render(monitors) - sidebar := m.viewLogsSidebar(rightW) + sidebar := m.viewLogsSidebar(rightW, m.maxTableRows+chromeTable) right := lipgloss.NewStyle().Width(rightW).Render(sidebar) top := lipgloss.JoinHorizontal(lipgloss.Top, left, right) if m.detailOpen { diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 49d1ac4..c28ea29 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -18,8 +18,8 @@ func (m Model) viewDetailInline(width int) string { var b strings.Builder - title := m.st.dangerStyle.Render("▶ " + site.Name) - b.WriteString(title + "\n") + title := m.st.titleStyle.Render(site.Name) + b.WriteString(" " + title + "\n") divW := width - 4 if divW < 20 { -- 2.52.0 From 08f14f3af8f2474500aab995a656eadefe89542c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:18:57 -0400 Subject: [PATCH 05/10] feat(tui): bordered log sidebar, Enter for full-screen detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log sidebar wrapped in rounded border (no left/bottom edge — shared with monitors table). Creates visual separation between panels. Enter on a monitor opens the full-screen detail view (existing stateDetail) for deep dive — history, SLA, probe results, connection chain. i stays as inline detail toggle. Footer key hints now context-sensitive: show h/s/Enter when detail is open, show full keybindings when closed. --- internal/tui/update.go | 8 +++++++- internal/tui/view_dashboard.go | 19 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/internal/tui/update.go b/internal/tui/update.go index 469e26e..efd1795 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -555,7 +555,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "n": return m.handleNewItem() - case "e", "enter": + case "enter": + if m.currentTab == tabMonitors && len(m.sites) > 0 { + m.state = stateDetail + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } + return m.handleEditItem() + case "e": return m.handleEditItem() case "t": if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index acf488a..c2a2fb8 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -160,8 +160,17 @@ func (m Model) viewDashboard() string { m.contentWidth = leftW monitors := m.viewSitesTab() left := lipgloss.NewStyle().Width(leftW).Render(monitors) - sidebar := m.viewLogsSidebar(rightW, m.maxTableRows+chromeTable) - right := lipgloss.NewStyle().Width(rightW).Render(sidebar) + sidebarBorder := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Border). + BorderLeft(false). + BorderBottom(false) + innerW := rightW - 2 + if innerW < 10 { + innerW = 10 + } + sidebar := m.viewLogsSidebar(innerW, m.maxTableRows) + right := sidebarBorder.Render(sidebar) top := lipgloss.JoinHorizontal(lipgloss.Top, left, right) if m.detailOpen { detail := m.viewDetailInline(availW) @@ -291,7 +300,11 @@ func (m Model) renderFooter(stats dashboardStats) string { var keys string switch m.currentTab { case tabMonitors: - keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" + if m.detailOpen { + keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [↑/↓]Select [T]Theme [q]Quit" + } else { + keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" + } case tabMaint: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case tabSettings: -- 2.52.0 From 065d5d74bb1b81355c1b03459efb51800244cafb Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:24:14 -0400 Subject: [PATCH 06/10] fix(tui): remove leading newline from bordered sidebar --- internal/tui/tab_logs_sidebar.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index ec012b3..1db54ba 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -74,5 +74,5 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { } } - return "\n" + sidebarStyle.Render(strings.Join(lines, "\n")) + return sidebarStyle.Render(strings.Join(lines, "\n")) } -- 2.52.0 From e5ac4a1fec0d8927d77a3acd36dc7141499d6d1d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:30:59 -0400 Subject: [PATCH 07/10] feat(tui): lazygit-style titled panel borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All panels wrapped in titled rounded borders (╭─ Title ──╮). Focused panel gets accent-colored border, unfocused panels get muted border. - Monitors panel: titled "Monitors", focused when detail is closed - Logs panel: titled "Logs", always unfocused (passive display) - Detail panel: titled with monitor name, focused when open Table's own RoundedBorder replaced with HiddenBorder — the titled panel border provides the visual frame, table uses space-separated columns internally. Consistent chrome across all panels. --- internal/tui/panel.go | 52 ++++++++++++++++++++++++++++++ internal/tui/table_helpers.go | 5 ++- internal/tui/view_dashboard.go | 43 ++++++++++++------------ internal/tui/view_detail_inline.go | 9 ------ 4 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 internal/tui/panel.go diff --git a/internal/tui/panel.go b/internal/tui/panel.go new file mode 100644 index 0000000..586af54 --- /dev/null +++ b/internal/tui/panel.go @@ -0,0 +1,52 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) titledPanel(title, content string, width int, focused bool) string { + borderColor := m.theme.Border + titleColor := m.theme.Muted + if focused { + borderColor = m.theme.Accent + titleColor = m.theme.Accent + } + + bc := lipgloss.NewStyle().Foreground(borderColor) + tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true) + + innerW := width - 2 + if innerW < 10 { + innerW = 10 + } + + titleRendered := tc.Render(" " + title + " ") + titleLen := len([]rune(title)) + 2 + fillLen := innerW - titleLen - 1 + if fillLen < 0 { + fillLen = 0 + } + + top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮") + + contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW) + inner := contentStyle.Render(content) + + var lines []string + lines = append(lines, top) + for _, line := range strings.Split(inner, "\n") { + lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│")) + } + lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯")) + + return strings.Join(lines, "\n") +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index e2dd5d8..0fd36ee 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -52,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en } t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(m.st.tableBorderStyle). + Border(lipgloss.HiddenBorder()). Width(tableWidth). Headers(headers...). Rows(rows...). @@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en return base }) - return "\n" + t.Render() + return t.Render() } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index c2a2fb8..d5d1a92 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -157,35 +157,38 @@ func (m Model) viewDashboard() string { availW := m.termWidth - chromePadH leftW := availW * 70 / 100 rightW := availW - leftW - m.contentWidth = leftW + m.contentWidth = leftW - 2 monitors := m.viewSitesTab() - left := lipgloss.NewStyle().Width(leftW).Render(monitors) - sidebarBorder := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Border). - BorderLeft(false). - BorderBottom(false) - innerW := rightW - 2 - if innerW < 10 { - innerW = 10 - } - sidebar := m.viewLogsSidebar(innerW, m.maxTableRows) - right := sidebarBorder.Render(sidebar) - top := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + monPanel := m.titledPanel("Monitors", monitors, leftW, !m.detailOpen) + sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows) + logPanel := m.titledPanel("Logs", sidebarContent, rightW, false) + top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel) if m.detailOpen { - detail := m.viewDetailInline(availW) - content = top + "\n" + detail + site := "" + if m.cursor < len(m.sites) { + site = m.sites[m.cursor].Name + } + detail := m.viewDetailInline(availW - 2) + detailPanel := m.titledPanel(site, detail, availW, true) + content = top + "\n" + detailPanel } else { content = top } } else { - m.contentWidth = m.termWidth + m.contentWidth = m.termWidth - 2 monitors := m.viewSitesTab() + availW := m.termWidth - chromePadH + monPanel := m.titledPanel("Monitors", monitors, availW, !m.detailOpen) if m.detailOpen { - detail := m.viewDetailInline(m.termWidth - chromePadH) - content = monitors + "\n" + detail + site := "" + if m.cursor < len(m.sites) { + site = m.sites[m.cursor].Name + } + detail := m.viewDetailInline(availW - 2) + detailPanel := m.titledPanel(site, detail, availW, true) + content = monPanel + "\n" + detailPanel } else { - content = monitors + content = monPanel } } case tabMaint: diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index c28ea29..da962c5 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -18,15 +18,6 @@ func (m Model) viewDetailInline(width int) string { var b strings.Builder - title := m.st.titleStyle.Render(site.Name) - b.WriteString(" " + title + "\n") - - divW := width - 4 - if divW < 20 { - divW = 20 - } - b.WriteString(" " + m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n") - status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) latency := m.fmtLatency(site.Latency) uptime := m.fmtUptime(hist.Statuses) -- 2.52.0 From 7109b6fa1c4c5371fb7d9e0a329fd2c41a581990 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:44:35 -0400 Subject: [PATCH 08/10] feat(tui): panel focus with click, scroll, and keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any panel (Monitors, Logs, Detail) to focus it — accent border follows focus. Mouse wheel scrolls the focused panel. Keyboard: l toggles log panel focus. Arrow keys scroll logs when focused, navigate monitors when not. Esc returns focus to monitors. Log sidebar now supports scroll offset — tracks position across renders without a viewport. Mouse wheel scrolls 3 lines, keyboard scrolls 1. --- internal/tui/tab_logs_sidebar.go | 44 ++++++++++++++++++++---- internal/tui/tui.go | 8 +++++ internal/tui/update.go | 58 +++++++++++++++++++++++++++++--- internal/tui/view_dashboard.go | 18 +++++----- 4 files changed, 110 insertions(+), 18 deletions(-) diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index 1db54ba..ca5949c 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -60,7 +60,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) - var lines []string + var all []string for _, line := range logs { if strings.TrimSpace(line) == "" { continue @@ -68,11 +68,43 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { if m.logFilterImportant && !isImportantLog(classifyLog(line)) { continue } - lines = append(lines, m.renderCompactLogLine(line, width)) - if maxLines > 0 && len(lines) >= maxLines { - break - } + all = append(all, m.renderCompactLogLine(line, width)) } - return sidebarStyle.Render(strings.Join(lines, "\n")) + 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 sidebarStyle.Render(strings.Join(visible, "\n")) +} + +func (m *Model) scrollLogs(delta int) { + logs := m.engine.GetLogs() + total := 0 + for _, line := range logs { + if strings.TrimSpace(line) == "" { + continue + } + if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + continue + } + total++ + } + + m.logScrollOffset += delta + if m.logScrollOffset < 0 { + m.logScrollOffset = 0 + } + if m.logScrollOffset > total-1 { + m.logScrollOffset = total - 1 + } + if m.logScrollOffset < 0 { + m.logScrollOffset = 0 + } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index dd97daf..7dce56f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -96,6 +96,12 @@ const ( sectionUsers = 2 ) +const ( + panelMonitors = 0 + panelLogs = 1 + panelDetail = 2 +) + type sessionState int const ( @@ -124,6 +130,8 @@ type Model struct { termWidth int termHeight int contentWidth int + focusedPanel int + logScrollOffset int editID int editToken string diff --git a/internal/tui/update.go b/internal/tui/update.go index efd1795..4dcc996 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -274,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if msg.Button == tea.MouseButtonWheelUp { + m.scrollLogs(-3) + } else { + m.scrollLogs(3) + } + return m, nil + } + listLen := m.currentListLen() if msg.Button == tea.MouseButtonWheelUp { if m.cursor > 0 { @@ -291,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } return m, nil } @@ -526,11 +538,26 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.currentTab == tabSettings { m.switchSettingsSection(m.settingsSection - 1) } - case "right", "l": + case "right": if m.currentTab == tabSettings { m.switchSettingsSection(m.settingsSection + 1) } + case "l": + switch m.currentTab { + case tabSettings: + m.switchSettingsSection(m.settingsSection + 1) + case tabMonitors: + if m.focusedPanel == panelLogs { + m.focusedPanel = panelMonitors + } else { + m.focusedPanel = panelLogs + } + } case "up", "k": + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + m.scrollLogs(-1) + return m, nil + } if m.cursor > 0 { m.cursor-- if m.cursor < m.tableOffset { @@ -542,6 +569,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } case "down", "j": + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + m.scrollLogs(1) + return m, nil + } max := m.currentListLen() - 1 if m.cursor < max { m.cursor++ @@ -600,9 +631,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateAlertDetail } case "esc": - if m.currentTab == tabMonitors && m.detailOpen { - m.detailOpen = false - m.recalcLayout() + if m.currentTab == tabMonitors { + if m.focusedPanel != panelMonitors { + m.focusedPanel = panelMonitors + } else if m.detailOpen { + m.detailOpen = false + m.recalcLayout() + } } case "h": if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { @@ -750,6 +785,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } + if m.currentTab == tabMonitors { + if m.zones.Get("panel-monitors").InBounds(msg) { + m.focusedPanel = panelMonitors + } 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 + } + } + prefix, listLen := m.currentZonePrefix() end := m.tableOffset + m.maxTableRows if end > listLen { @@ -759,6 +806,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { m.cursor = i m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } return m, nil } } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index d5d1a92..d218b73 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -159,9 +159,9 @@ func (m Model) viewDashboard() string { rightW := availW - leftW m.contentWidth = leftW - 2 monitors := m.viewSitesTab() - monPanel := m.titledPanel("Monitors", monitors, leftW, !m.detailOpen) + monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors)) sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows) - logPanel := m.titledPanel("Logs", sidebarContent, rightW, false) + logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs)) top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel) if m.detailOpen { site := "" @@ -169,7 +169,7 @@ func (m Model) viewDashboard() string { site = m.sites[m.cursor].Name } detail := m.viewDetailInline(availW - 2) - detailPanel := m.titledPanel(site, detail, availW, true) + detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) content = top + "\n" + detailPanel } else { content = top @@ -178,14 +178,14 @@ func (m Model) viewDashboard() string { m.contentWidth = m.termWidth - 2 monitors := m.viewSitesTab() availW := m.termWidth - chromePadH - monPanel := m.titledPanel("Monitors", monitors, availW, !m.detailOpen) + monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors)) if m.detailOpen { site := "" if m.cursor < len(m.sites) { site = m.sites[m.cursor].Name } detail := m.viewDetailInline(availW - 2) - detailPanel := m.titledPanel(site, detail, availW, true) + detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) content = monPanel + "\n" + detailPanel } else { content = monPanel @@ -303,10 +303,12 @@ func (m Model) renderFooter(stats dashboardStats) string { var keys string switch m.currentTab { case tabMonitors: - if m.detailOpen { - keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [↑/↓]Select [T]Theme [q]Quit" + if m.focusedPanel == panelLogs { + keys = "[↑/↓]Scroll [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 { - keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" + keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit" } case tabMaint: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" -- 2.52.0 From 81f8c71b6f568870c1aeaca0fe91007682282da1 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 19:50:13 -0400 Subject: [PATCH 09/10] feat(tui): persist detail panel state as user preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail open/closed state saved via SetPreference on toggle and restored on session start. Same pattern as theme persistence — survives restarts and works per-user over SSH. --- internal/tui/tui.go | 3 +++ internal/tui/update.go | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7dce56f..6f39829 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -219,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri } } + detailPref, _ := s.GetPreference(context.Background(), "detail_open") + return Model{ state: stateDashboard, logViewport: vpLogs, @@ -232,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri theme: theme, themeIndex: themeIdx, st: newStyles(theme), + detailOpen: detailPref == "true", demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, sparkTooltipIdx: -1, diff --git a/internal/tui/update.go b/internal/tui/update.go index 4dcc996..1004871 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -624,9 +624,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.currentTab == tabMonitors && len(m.sites) > 0 { m.detailOpen = !m.detailOpen m.recalcLayout() + st := m.store + open := m.detailOpen + var cmd tea.Cmd if m.detailOpen { - return m, m.loadDetailCmd(m.sites[m.cursor].ID) + cmd = m.loadDetailCmd(m.sites[m.cursor].ID) } + saveCmd := writeCmd("Save detail preference", func() error { + v := "false" + if open { + v = "true" + } + return st.SetPreference(context.Background(), "detail_open", v) + }) + if cmd != nil { + return m, tea.Batch(cmd, saveCmd) + } + return m, saveCmd } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } @@ -637,6 +651,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if m.detailOpen { m.detailOpen = false m.recalcLayout() + st := m.store + return m, writeCmd("Save detail preference", func() error { + return st.SetPreference(context.Background(), "detail_open", "false") + }) } } case "h": -- 2.52.0 From 01dd53241a5b20b5f42c7f6cff76083bea1f3ee4 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 20:04:08 -0400 Subject: [PATCH 10/10] refactor: separate log timestamp from message as structured LogEntry Introduced models.LogEntry{Message, CreatedAt} to replace raw strings in the log pipeline. Timestamps are now formatted at render time, not baked into stored messages. - Engine: appendLog stores LogEntry with time.Now() - Store: LoadLogs returns []LogEntry, selects created_at from DB - Store: strips legacy [HH:MM] prefix from pre-refactor DB entries - TUI: sidebar shows "MM/DD HH:MM" from CreatedAt - TUI: full log view shows "MM/DD HH:MM" from CreatedAt - SaveLog still receives plain message string (DB handles timestamp) --- internal/models/models.go | 5 ++++ internal/monitor/monitor.go | 24 ++++++++++--------- internal/monitor/monitor_test.go | 15 +++++++----- internal/store/sqlstore.go | 23 +++++++++++------- internal/store/sqlstore_test.go | 2 +- internal/store/store.go | 2 +- internal/store/storetest/mock.go | 4 ++-- internal/tui/tab_logs.go | 37 ++++++++++------------------- internal/tui/tab_logs_sidebar.go | 40 +++++++++++--------------------- internal/tui/update_test.go | 2 +- 10 files changed, 73 insertions(+), 81 deletions(-) diff --git a/internal/models/models.go b/internal/models/models.go index b8983a4..319cbc1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -86,6 +86,11 @@ type ProbeNode struct { Version string } +type LogEntry struct { + Message string + CreatedAt time.Time +} + // AlertHealthRecord is the persisted send health of an alert channel. It lets the // "last sent" / health indicators survive restarts instead of resetting to "never". type AlertHealthRecord struct { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 779667c..929f99e 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -40,7 +40,7 @@ type Engine struct { liveState map[int]models.Site logMu sync.RWMutex - logStore []string + logStore []models.LogEntry activeMu sync.RWMutex isActive bool @@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string { // appendLog adds a timestamped entry to the in-memory ring buffer and returns // it. It never touches the database, so it is safe to call from the db-write // drop/error path without recursing back through the write queue. -func (e *Engine) appendLog(msg string) string { - ts := time.Now().Format("15:04:05") - entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg)) +func (e *Engine) appendLog(msg string) models.LogEntry { + entry := models.LogEntry{ + Message: sanitizeLog(msg), + CreatedAt: time.Now(), + } e.logMu.Lock() - e.logStore = append([]string{entry}, e.logStore...) + e.logStore = append([]models.LogEntry{entry}, e.logStore...) if len(e.logStore) > maxLogEntries { e.logStore = e.logStore[:maxLogEntries] } @@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string { func (e *Engine) AddLog(msg string) { entry := e.appendLog(msg) - e.enqueueWrite(writeLog{message: entry}) + e.enqueueWrite(writeLog{message: entry.Message}) } // enqueueWrite hands a persistence task to the writer goroutine without @@ -246,16 +248,16 @@ func (e *Engine) Stop() { } func (e *Engine) InitLogs() { - logs, err := e.db.LoadLogs(context.Background(), maxLogEntries) + entries, err := e.db.LoadLogs(context.Background(), maxLogEntries) if err != nil { return } - if len(logs) == 0 { + if len(entries) == 0 { return } e.logMu.Lock() defer e.logMu.Unlock() - e.logStore = logs + e.logStore = entries } // InitAlertHealth restores persisted alert send health so the dashboard shows real @@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() { } } -func (e *Engine) GetLogs() []string { +func (e *Engine) GetLogs() []models.LogEntry { e.logMu.RLock() defer e.logMu.RUnlock() - logs := make([]string, len(e.logStore)) + logs := make([]models.LogEntry, len(e.logStore)) copy(logs, e.logStore) return logs } diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 45b8925..8ebd0c6 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -25,7 +25,7 @@ type mockStore struct { sites []models.SiteConfig alerts map[int]models.AlertConfig maintenance map[int]bool - logs []string + logs []models.LogEntry history map[int][]models.CheckRecord savedChecks []savedCheck savedLogs []string @@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { +func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return m.logs, nil } @@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) { logs := e.GetLogs() found := false for _, l := range logs { - if containsStr(l, "suppressed") { + if containsStr(l.Message, "suppressed") { found = true break } @@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) { if len(logs) != 100 { t.Errorf("expected 100 logs, got %d", len(logs)) } - if !containsStr(logs[0], "log-104") { - t.Errorf("expected newest log first, got %s", logs[0]) + if !containsStr(logs[0].Message, "log-104") { + t.Errorf("expected newest log first, got %s", logs[0].Message) } } func TestInitLogs_LoadsFromDB(t *testing.T) { ms := newMockStore() - ms.logs = []string{"old-log-1", "old-log-2"} + ms.logs = []models.LogEntry{ + {Message: "old-log-1"}, + {Message: "old-log-2"}, + } e := newTestEngine(ms) e.InitLogs() diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 0440bee..b6a96cb 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error { return err } -func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) { - rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) +func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) { + rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit) if err != nil { return nil, err } defer rows.Close() - var logs []string + var entries []models.LogEntry for rows.Next() { - var msg string - if err := rows.Scan(&msg); err != nil { - return logs, err + var e models.LogEntry + if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil { + return entries, err } - logs = append(logs, msg) + // Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries. + if len(e.Message) > 3 && e.Message[0] == '[' { + if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 { + e.Message = strings.TrimSpace(e.Message[idx+1:]) + } + } + entries = append(entries, e) } - return logs, rows.Err() + return entries, rows.Err() } func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index 01dbffe..85467c3 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) { // LoadLogs ordering ties when rows share a created_at second). present := make(map[string]bool, len(logs)) for _, l := range logs { - present[l] = true + present[l.Message] = true } if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] { t.Error("newest log was pruned") diff --git a/internal/store/store.go b/internal/store/store.go index 53033bb..861b3dc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -61,7 +61,7 @@ type Store interface { // Logs SaveLog(ctx context.Context, message string) error - LoadLogs(ctx context.Context, limit int) ([]string, error) + LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) PruneLogs(ctx context.Context) error // Maintenance Windows diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 04b04bc..759fbd2 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error { return nil } -func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } +func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil } +func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { if m.GetActiveMaintenanceWindowsFunc != nil { diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 29463ea..4d1ed8e 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) type logSeverity int @@ -15,8 +17,8 @@ const ( severitySystem ) -func classifyLog(line string) logSeverity { - lower := strings.ToLower(line) +func classifyLog(msg string) logSeverity { + lower := strings.ToLower(msg) switch { case strings.Contains(lower, "confirmed down"), strings.Contains(lower, "is down"), @@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string { } } -func (m Model) renderLogLine(line string) string { - sev := classifyLog(line) +func (m Model) renderLogLine(entry models.LogEntry) string { + sev := classifyLog(entry.Message) tag := m.renderLogTag(sev) - - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = m.st.subtleStyle.Render(line[1:idx]) - msg = strings.TrimSpace(line[idx+1:]) - } - } - - if ts != "" { - return fmt.Sprintf(" %s %s %s", ts, tag, msg) - } - return fmt.Sprintf(" %s %s", tag, msg) + ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04")) + return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message) } -// refreshLogContent rebuilds the log viewport from the full engine log list, -// filtering before windowing so the entry count and "(n hidden)" reflect all -// logs, not just the visible viewport slice. func (m *Model) refreshLogContent() { var rendered []string total := 0 shown := 0 - for _, line := range m.engine.GetLogs() { - if strings.TrimSpace(line) == "" { + for _, entry := range m.engine.GetLogs() { + if strings.TrimSpace(entry.Message) == "" { continue } total++ - sev := classifyLog(line) + sev := classifyLog(entry.Message) if m.logFilterImportant && !isImportantLog(sev) { continue } shown++ - rendered = append(rendered, m.renderLogLine(line)) + rendered = append(rendered, m.renderLogLine(entry)) } m.logTotal = total diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index ca5949c..05b4e8e 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -3,11 +3,12 @@ package tui import ( "strings" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/charmbracelet/lipgloss" ) -func (m Model) renderCompactLogLine(line string, maxW int) string { - sev := classifyLog(line) +func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string { + sev := classifyLog(entry.Message) var tag string switch sev { @@ -23,33 +24,20 @@ func (m Model) renderCompactLogLine(line string, maxW int) string { tag = m.st.subtleStyle.Render("·") } - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = line[1:idx] - msg = strings.TrimSpace(line[idx+1:]) - } - } + ts := entry.CreatedAt.Local().Format("01/02 15:04") + msg := entry.Message msg = strings.TrimPrefix(msg, "Monitor ") msg = strings.TrimPrefix(msg, "Push ") - // prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp - prefixW := 3 - if ts != "" { - prefixW = len(ts) + 4 - } + prefixW := len(ts) + 4 msgW := maxW - prefixW if msgW < 5 { msgW = 5 } msg = limitStr(msg, msgW) - if ts != "" { - return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg - } - return " " + tag + " " + msg + return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg } func (m Model) viewLogsSidebar(width, maxLines int) string { @@ -61,14 +49,14 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) var all []string - for _, line := range logs { - if strings.TrimSpace(line) == "" { + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { continue } - if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { continue } - all = append(all, m.renderCompactLogLine(line, width)) + all = append(all, m.renderCompactLogLine(entry, width)) } start := m.logScrollOffset @@ -87,11 +75,11 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { func (m *Model) scrollLogs(delta int) { logs := m.engine.GetLogs() total := 0 - for _, line := range logs { - if strings.TrimSpace(line) == "" { + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { continue } - if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { continue } total++ diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 1a44013..11b98f6 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) { mm := updated.(Model) found := false for _, line := range mm.engine.GetLogs() { - if strings.Contains(line, "Delete site failed: boom") { + if strings.Contains(line.Message, "Delete site failed: boom") { found = true } } -- 2.52.0