From ba75be194d5729fa34ed70be9cd9c2b090d8fc83 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 15:08:29 -0400 Subject: [PATCH 1/7] refactor(tui): consistent chrome across all views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract divider() and emptyState() helpers to format.go - All empty states now use bordered box with accent color - Detail and alert detail panels get header/section dividers - SLA label width 14→16 to match detail/alert panels - Logs key hints moved from content to dashboard footer - History/SLA panels use shared divider helper --- internal/tui/format.go | 26 ++++++++++++++++++++++++++ internal/tui/tab_alerts.go | 10 ++++++---- internal/tui/tab_logs.go | 4 ++-- internal/tui/tab_maint.go | 2 +- internal/tui/tab_nodes.go | 2 +- internal/tui/tab_sites.go | 11 +---------- internal/tui/tab_users.go | 2 +- internal/tui/view_dashboard.go | 2 +- internal/tui/view_detail.go | 8 +++++--- internal/tui/view_history.go | 11 ++++------- internal/tui/view_sla.go | 30 ++++++++++++------------------ 11 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/tui/format.go b/internal/tui/format.go index f72834a..25fa9cb 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -2,11 +2,37 @@ package tui import ( "fmt" + "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "github.com/charmbracelet/lipgloss" ) +func (m Model) dividerWidth() int { + w := m.termWidth - chromePadH - 4 + if w < 40 { + w = 40 + } + return w +} + +func (m Model) divider() string { + return " " + subtleStyle.Render(strings.Repeat("─", m.dividerWidth())) +} + +func (m Model) emptyState(message, hint string) string { + content := message + if hint != "" { + content += "\n\n" + subtleStyle.Render(hint) + } + return "\n" + lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Accent). + Padding(1, 3). + Render(content) +} + func limitStr(text string, max int) string { runes := []rune(text) if len(runes) > max { diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 7865e40..cd0971a 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -164,7 +164,7 @@ func fmtAlertLastSent(h monitor.AlertHealth) string { func (m Model) viewAlertsTab() string { if len(m.alerts) == 0 { - return "\n No alert channels configured. Press [n] to add one." + return m.emptyState("No alert channels configured.", "[n] Add your first alert") } var headers []string @@ -214,7 +214,8 @@ func (m Model) viewAlertDetailPanel() string { var b strings.Builder - b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n") + b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n") + b.WriteString(m.divider() + "\n") row := func(label, value string) { fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) @@ -240,12 +241,13 @@ func (m Model) viewAlertDetailPanel() string { row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60))) } - b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n") + b.WriteString(m.divider() + "\n") + b.WriteString(subtleStyle.Render(" CONFIGURATION") + "\n") for k, v := range a.Settings { row(k, v) } - b.WriteString("\n\n") + b.WriteString(m.divider() + "\n") b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 0633001..415e77d 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -85,7 +85,7 @@ func renderLogLine(line string) string { func (m Model) viewLogsTab() string { content := m.logViewport.View() if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { - return "\n No log entries yet. Logs appear as monitors run checks." + return m.emptyState("No log entries yet.", "Logs appear as monitors run checks") } lines := strings.Split(content, "\n") @@ -112,7 +112,7 @@ func (m Model) viewLogsTab() string { } header := subtleStyle.Render(fmt.Sprintf( - " %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel)) + " %d entries Filter: %s", shown, filterLabel)) if m.logFilterImportant && shown < total { header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 1a3a7ca..1923f3a 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -93,7 +93,7 @@ func (m Model) isMonitorInMaintenance(monitorID int) bool { func (m Model) viewMaintTab() string { if len(m.maintenanceWindows) == 0 { - return "\n No maintenance windows or incidents. Press [n] to create one." + return m.emptyState("No maintenance windows or incidents.", "[n] Create one") } var headers []string diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index 3ddb79d..14f49f4 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -7,7 +7,7 @@ import ( func (m Model) viewNodesTab() string { if len(m.nodes) == 0 { - return "\n No probe nodes connected." + return m.emptyState("No probe nodes connected.", "") } var headers []string diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 7e05227..5d552be 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -101,16 +101,7 @@ func (m Model) computeLayout() tableLayout { func (m Model) viewSitesTab() string { if len(m.sites) == 0 { - welcome := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Accent). - Padding(1, 3). - Render( - titleStyle.Render("uptop") + "\n\n" + - "No monitors configured yet.\n\n" + - subtleStyle.Render("[n] Add your first monitor"), - ) - return "\n" + welcome + return m.emptyState(titleStyle.Render("uptop")+"\n\nNo monitors configured yet.", "[n] Add your first monitor") } layout := m.computeLayout() diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index a02badd..b6967d8 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -29,7 +29,7 @@ func fmtKey(key string) string { func (m Model) viewUsersTab() string { if len(m.users) == 0 { - return "\n No users configured. Press [n] to add one." + return m.emptyState("No users configured.", "[n] Add a user") } var headers []string diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 504a7fa..3c80a92 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -266,7 +266,7 @@ func (m Model) renderFooter(stats dashboardStats) string { case 1: keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" case 2: - keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit" + keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit" case 4: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case 5: diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 20c8542..5431d0c 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -30,7 +30,8 @@ func (m Model) viewDetailPanel() string { if breadcrumb == "" { breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name) } - b.WriteString(breadcrumb + "\n\n") + b.WriteString(breadcrumb + "\n") + b.WriteString(m.divider() + "\n") row := func(label, value string) { fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) @@ -200,7 +201,7 @@ func (m Model) viewDetailPanel() string { b.WriteString(" " + subtleStyle.Render("[h] History") + "\n") } - b.WriteString("\n") + b.WriteString(m.divider() + "\n") const sparkWidth = 40 if site.Type == "push" { b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) @@ -242,7 +243,8 @@ func (m Model) viewDetailPanel() string { } } - b.WriteString("\n\n") + b.WriteString("\n") + b.WriteString(m.divider() + "\n") b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [q] Quit")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index 5dcbbd7..40072eb 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -153,16 +153,13 @@ func (m Model) viewHistoryPanel() string { header += " " + subtleStyle.Render("[q] Back") b.WriteString(header + "\n") - divWidth := m.termWidth - chromePadH - 4 - if divWidth < 40 { - divWidth = 40 - } - b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + divWidth := m.dividerWidth() + b.WriteString(m.divider() + "\n") sparkline := stateChangeSparkline(m.historyChanges, divWidth) if sparkline != "" { b.WriteString(" " + sparkline + "\n") - b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + b.WriteString(m.divider() + "\n") } fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n", @@ -177,7 +174,7 @@ func (m Model) viewHistoryPanel() string { b.WriteString(m.historyViewport.View()) } - b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + b.WriteString("\n" + m.divider() + "\n") stats := computeHistoryStats(m.historyChanges) parts := []string{fmt.Sprintf("%d events", stats.totalEvents)} diff --git a/internal/tui/view_sla.go b/internal/tui/view_sla.go index cd6bac7..980881e 100644 --- a/internal/tui/view_sla.go +++ b/internal/tui/view_sla.go @@ -27,20 +27,14 @@ func (m Model) viewSLAPanel() string { header := " " + titleStyle.Render("SLA REPORT: "+m.slaSiteName) header += " " + subtleStyle.Render("[q] Back") b.WriteString(header + "\n") - - divWidth := m.termWidth - chromePadH - 4 - if divWidth < 40 { - divWidth = 40 - } - b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + b.WriteString(m.divider() + "\n") period := slaPeriods[m.slaPeriodIdx] b.WriteString(" " + subtleStyle.Render("Period: Last "+period.label) + "\n\n") r := m.slaReport - // Uptime bar - barWidth := divWidth - 30 + barWidth := m.dividerWidth() - 30 if barWidth < 10 { barWidth = 10 } @@ -52,23 +46,23 @@ func (m Model) viewSLAPanel() string { if r.UptimePct < 99.0 { uptimeColor = dangerStyle } - fmt.Fprintf(&b, " %-14s %s %s\n", subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) - fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) - fmt.Fprintf(&b, " %-14s %d\n", subtleStyle.Render("Outages"), r.OutageCount) + fmt.Fprintf(&b, " %-16s %s %s\n", subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) + fmt.Fprintf(&b, " %-16s %d\n", subtleStyle.Render("Outages"), r.OutageCount) if r.OutageCount > 0 { - fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) - fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) - fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) } - b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + b.WriteString("\n" + m.divider() + "\n") if len(m.slaDailyBreakdown) > 0 { b.WriteString(m.slaViewport.View()) } - b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + b.WriteString("\n" + m.divider() + "\n") var keys []string for i, p := range slaPeriods { @@ -80,7 +74,7 @@ func (m Model) viewSLAPanel() string { } } b.WriteString(" " + strings.Join(keys, " ")) - b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll")) + b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } @@ -88,7 +82,7 @@ func (m Model) viewSLAPanel() string { func (m Model) buildSLADailyContent() string { var b strings.Builder - barWidth := m.termWidth - chromePadH - 30 + barWidth := m.dividerWidth() - 30 if barWidth < 10 { barWidth = 10 } -- 2.52.0 From e0f189efe9fb5afb1541fcc312e8fd88a23210f0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 15:36:21 -0400 Subject: [PATCH 2/7] fix(tui): logs tab use viewport for scrollable content Logs were dumping all lines directly, pushing the dashboard footer off screen. Now uses logViewport with proper height accounting so footer stays visible and scrolling works. --- internal/tui/tab_logs.go | 3 ++- internal/tui/update.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 415e77d..6dd439b 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -118,5 +118,6 @@ func (m Model) viewLogsTab() string { header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) } - return "\n" + header + "\n\n" + strings.Join(rendered, "\n") + m.logViewport.SetContent(strings.Join(rendered, "\n")) + return "\n" + header + "\n\n" + m.logViewport.View() } diff --git a/internal/tui/update.go b/internal/tui/update.go index 3527ded..72ad092 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -127,7 +127,7 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.maxTableRows = 1 } m.logViewport.Width = msg.Width - chromePadH - m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) + m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter + 3) m.historyViewport.Width = msg.Width - chromePadH m.historyViewport.Height = msg.Height - 10 m.slaViewport.Width = msg.Width - chromePadH -- 2.52.0 From aae6e6e65e4f9aaccb084c5a52ec54a843027c83 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 15:59:32 -0400 Subject: [PATCH 3/7] fix(tui): pin footer to bottom of terminal Replace string concatenation layout with lipgloss.JoinVertical and fixed-height content area. Footer now stays at the same vertical position regardless of tab content height. Uses lipgloss.Height() to dynamically measure header/footer and fill remaining space. --- internal/tui/view_dashboard.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 3c80a92..2feaed8 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -173,11 +173,20 @@ func (m Model) viewDashboard() string { footer := m.renderFooter(stats) - s := lipgloss.NewStyle().Padding(1, 2) - if m.termHeight > 0 { - s = s.MaxHeight(m.termHeight) + outerPad := lipgloss.NewStyle().Padding(1, 2) + _, frameV := outerPad.GetFrameSize() + availHeight := m.termHeight - frameV + if availHeight < 5 { + availHeight = 5 } - return s.Render(header + "\n" + content + "\n" + footer) + + contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer) - 2 + if contentHeight < 1 { + contentHeight = 1 + } + paddedContent := lipgloss.NewStyle().Height(contentHeight).Render(content) + + return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer)) } func (m Model) renderTabBar(stats dashboardStats) string { -- 2.52.0 From d4a2e9dd535aec3e9af969f450b8e1f90078f9b0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 16:03:57 -0400 Subject: [PATCH 4/7] fix(tui): normalize content whitespace for consistent footer position Each tab returned different leading newlines (Sites/tables: 1, Logs: 3, empty states: varies). TrimSpace content before layout so JoinVertical controls all spacing. Remove leading \n from footer since JoinVertical handles gaps. --- internal/tui/view_dashboard.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 2feaed8..04a7f3a 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -171,6 +171,7 @@ func (m Model) viewDashboard() string { } } + content = strings.TrimSpace(content) footer := m.renderFooter(stats) outerPad := lipgloss.NewStyle().Padding(1, 2) @@ -285,9 +286,9 @@ func (m Model) renderFooter(stats dashboardStats) string { } ver := subtleStyle.Render("v" + m.version) - footer := "\n" + statusLine + " " + subtleStyle.Render(keys) + " " + ver + footer := statusLine + " " + subtleStyle.Render(keys) + " " + ver if m.filterText != "" && m.currentTab == 0 { - footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + " " + ver + footer = subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + " " + ver } return footer } -- 2.52.0 From cdb8c356e9dd4365e1fd798abf6c76b994a36854 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 16:07:44 -0400 Subject: [PATCH 5/7] fix(tui): clip overflowing content to keep footer pinned Sites table with many rows exceeded the fixed content height, pushing footer down. MaxHeight now clips content that overflows while Height still pads shorter content upward. --- internal/tui/view_dashboard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 04a7f3a..f3e4e78 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -185,7 +185,7 @@ func (m Model) viewDashboard() string { if contentHeight < 1 { contentHeight = 1 } - paddedContent := lipgloss.NewStyle().Height(contentHeight).Render(content) + paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content) return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer)) } -- 2.52.0 From d099740f3362a191c097ccac49b9105a7afd1748 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 16:12:14 -0400 Subject: [PATCH 6/7] fix(tui): remove extra blank lines above footer JoinVertical adds no gap lines between sections. The - 2 subtraction was over-reserving space, leaving 2 blank lines between content and footer. --- internal/tui/view_dashboard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index f3e4e78..5fa9a2b 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -181,7 +181,7 @@ func (m Model) viewDashboard() string { availHeight = 5 } - contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer) - 2 + contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer) if contentHeight < 1 { contentHeight = 1 } -- 2.52.0 From 33a3ff9bcb58ba45b5fbfbfb1fe96b300075d6af Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 16:13:40 -0400 Subject: [PATCH 7/7] fix(tui): expand log viewport to fill content area Previous + 3 over-restricted viewport height, leaving blank lines at the bottom of the logs tab. --- internal/tui/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/update.go b/internal/tui/update.go index 72ad092..9f3d33d 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -127,7 +127,7 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.maxTableRows = 1 } m.logViewport.Width = msg.Width - chromePadH - m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter + 3) + m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2) m.historyViewport.Width = msg.Width - chromePadH m.historyViewport.Height = msg.Height - 10 m.slaViewport.Width = msg.Width - chromePadH -- 2.52.0