From af5246e7772385aecc199da5b39f49cf0bdd7742 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:18:27 -0400 Subject: [PATCH 01/20] =?UTF-8?q?chore(tui):=20visual=20polish=20=E2=80=94?= =?UTF-8?q?=20detail=20sections,=20column=20headers,=20alert=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail panel: - Grouped fields into sections (ENDPOINT, TIMING, HTTP, CONFIG) - Omit Timeout when 0 (unconfigured) - Omit Method when default GET - Show explicit "200-299" when AcceptedCodes empty Table: - LATENCY header → LAT (design short, never truncate) Alerts: - Press [i] for alert detail panel: full config, health status, send counts, last error - Keybinding display updated with [i]Info Bundled remaining UX polish items from screenshot review. --- internal/tui/tab_alerts.go | 47 +++++++++++++++++++++++++++++++++++ internal/tui/tab_sites.go | 50 ++++++++++++++++++++++++++------------ internal/tui/tui.go | 15 +++++++++++- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index b86d905..0ac4316 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "time" "gitea.lerkolabs.com/lerko/uptop/internal/monitor" @@ -173,6 +174,52 @@ func (m Model) viewAlertsTab() string { ) } +func (m Model) viewAlertDetailPanel() string { + if m.cursor >= len(m.alerts) { + return "" + } + a := m.alerts[m.cursor] + h := m.engine.GetAlertHealth(a.ID) + + var b strings.Builder + + b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n") + + row := func(label, value string) { + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) + } + + row("Type", fmtAlertType(a.Type)) + + if h.LastSendAt.IsZero() { + row("Health", subtleStyle.Render("never sent")) + } else if h.LastSendOK { + row("Health", specialStyle.Render("OK")) + } else { + row("Health", dangerStyle.Render("FAILED")) + } + + if !h.LastSendAt.IsZero() { + row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")") + } + if h.SendCount > 0 { + row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)) + } + if h.LastError != "" { + row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60))) + } + + b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n") + for k, v := range a.Settings { + row(k, v) + } + + b.WriteString("\n\n") + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} + func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ AlertType: "discord", diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 838ea00..c35cafe 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -378,7 +378,7 @@ func (m Model) viewSitesTab() string { var groupRows map[int]bool return m.renderTable( - []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"}, + []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UPTIME", "HISTORY", "SSL", "RETRY"}, len(m.sites), func(start, end int) [][]string { groupRows = make(map[int]bool) @@ -764,6 +764,10 @@ func (m Model) viewDetailPanel() string { fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) } + section := func(label string) { + b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n") + } + row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { @@ -792,6 +796,8 @@ func (m Model) viewDetailPanel() string { } } } + + section("ENDPOINT") row("Type", site.Type) if site.URL != "" { row("URL", site.URL) @@ -802,31 +808,45 @@ func (m Model) viewDetailPanel() string { if site.Port > 0 { row("Port", strconv.Itoa(site.Port)) } + + section("TIMING") row("Interval", fmt.Sprintf("%ds", site.Interval)) - row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + if site.Timeout > 0 { + row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + } row("Latency", fmtLatency(site.Latency)) row("Uptime", fmtUptime(hist.Statuses)) + if !site.LastCheck.IsZero() { + row("Last Check", site.LastCheck.Format("15:04:05")) + } if site.Type == "http" { - row("Method", site.Method) - row("Codes", site.AcceptedCodes) + section("HTTP") + if site.Method != "" && site.Method != "GET" { + row("Method", site.Method) + } + codes := site.AcceptedCodes + if codes == "" { + codes = "200-299" + } + row("Codes", codes) row("SSL", fmtSSL(site)) if site.IgnoreTLS { row("TLS Verify", dangerStyle.Render("disabled")) } } - if site.MaxRetries > 0 { - row("Retries", fmtRetries(site)) - } - if site.Regions != "" { - row("Regions", site.Regions) - } - if site.Description != "" { - row("Description", site.Description) - } - if !site.LastCheck.IsZero() { - row("Last Check", site.LastCheck.Format("15:04:05")) + if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" { + section("CONFIG") + if site.MaxRetries > 0 { + row("Retries", fmtRetries(site)) + } + if site.Regions != "" { + row("Regions", site.Regions) + } + if site.Description != "" { + row("Description", site.Description) + } } probeResults := m.engine.GetProbeResults(site.ID) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 3b0418a..0677643 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -68,6 +68,7 @@ const ( stateLogs stateUsers stateDetail + stateAlertDetail stateFormSite stateFormAlert stateFormUser @@ -384,6 +385,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } return m, nil + case stateAlertDetail: + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil case stateDashboard, stateLogs, stateUsers: switch msg.String() { case "q": @@ -497,6 +506,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "i": if m.currentTab == 0 && len(m.sites) > 0 { m.state = stateDetail + } else if m.currentTab == 1 && len(m.alerts) > 0 { + m.state = stateAlertDetail } case "x": if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { @@ -818,6 +829,8 @@ func (m Model) View() string { return "" case stateDetail: return m.viewDetailPanel() + case stateAlertDetail: + return m.viewAlertDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } @@ -954,7 +967,7 @@ func (m Model) viewDashboard() string { case 0: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" case 1: - keys = "[n]New [e]Edit [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" + 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" case 4: -- 2.52.0 From 82d7b2942b9d423e2047bd33bc6c7420ad482e0e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:28:08 -0400 Subject: [PATCH 02/20] =?UTF-8?q?feat(tui):=20responsive=20table=20columns?= =?UTF-8?q?=20=E2=80=94=20expand=20headers=20with=20terminal=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded column widths with dynamic layout system: - Each column has short/full header and min/max width - At narrow terminals: LAT, UP%, RT, compact widths - At wide terminals: LATENCY, UPTIME, RETRIES, expanded widths - Surplus space distributed left-to-right across expandable columns - Headers switch between short/full based on actual column width Column definitions: # (4-6) TYPE (8-10) STATUS (8-10) LAT/LATENCY (5-10) UP%/UPTIME (5-8) SSL (5-7) RT/RETRIES (5-9) --- internal/tui/tab_sites.go | 105 +++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 13 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index c35cafe..36d490e 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -334,15 +334,50 @@ func fmtDuration(d time.Duration) string { return fmt.Sprintf("%dd", days) } -func (m Model) dynamicWidths() (nameW, sparkW int) { - fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY - overhead := 30 // cell padding + borders - avail := m.termWidth - chromePadH - 2 - fixed - overhead - if avail < 30 { - avail = 30 +type tableLayout struct { + nameW, sparkW int + headers []string + colWidths []int +} + +func (m Model) computeLayout() tableLayout { + type colDef struct { + short string + full string + minWidth int + maxWidth int } - nameW = avail / 2 - sparkW = avail - nameW - 2 // -2 for spark column padding + + cols := []colDef{ + {"#", "#", 4, 6}, + {"", "", 0, 0}, // NAME (dynamic) + {"TYPE", "TYPE", 8, 10}, + {"STATUS", "STATUS", 8, 10}, + {"LAT", "LATENCY", 5, 10}, + {"UP%", "UPTIME", 5, 8}, + {"", "", 0, 0}, // HISTORY (dynamic) + {"SSL", "SSL", 5, 7}, + {"RT", "RETRIES", 5, 9}, + } + + overhead := 30 + usable := m.termWidth - chromePadH - 2 - overhead + if usable < 80 { + usable = 80 + } + + fixedMin := 0 + for i, c := range cols { + if i == 1 || i == 6 { + continue + } + fixedMin += c.minWidth + } + + avail := usable - fixedMin + nameW := avail / 2 + sparkW := avail - nameW - 2 + if nameW < 13 { nameW = 13 } @@ -355,7 +390,50 @@ func (m Model) dynamicWidths() (nameW, sparkW int) { if sparkW > 60 { sparkW = 60 } - return + + surplus := usable - fixedMin - nameW - sparkW - 2 + if surplus < 0 { + surplus = 0 + } + + headers := make([]string, len(cols)) + widths := make([]int, len(cols)) + for i, c := range cols { + if i == 1 { + headers[i] = "NAME" + widths[i] = 0 + continue + } + if i == 6 { + headers[i] = "HISTORY" + widths[i] = sparkW + 2 + continue + } + + w := c.minWidth + expand := c.maxWidth - c.minWidth + if surplus >= expand { + w = c.maxWidth + surplus -= expand + } else if surplus > 0 { + w += surplus + surplus = 0 + } + + if w >= len(c.full)+2 { + headers[i] = c.full + } else { + headers[i] = c.short + } + widths[i] = w + } + + return tableLayout{ + nameW: nameW, + sparkW: sparkW, + headers: headers, + colWidths: widths, + } } func (m Model) viewSitesTab() string { @@ -373,12 +451,13 @@ func (m Model) viewSitesTab() string { return "\n" + welcome } - nameW, sparkWidth := m.dynamicWidths() - colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} + layout := m.computeLayout() + nameW := layout.nameW + sparkWidth := layout.sparkW var groupRows map[int]bool return m.renderTable( - []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UPTIME", "HISTORY", "SSL", "RETRY"}, + layout.headers, len(m.sites), func(start, end int) [][]string { groupRows = make(map[int]bool) @@ -444,7 +523,7 @@ func (m Model) viewSitesTab() string { } return rows }, - colWidths, + layout.colWidths, func(row, col int) *lipgloss.Style { if groupRows[row] { s := siteGroupStyle -- 2.52.0 From ecdb1a663281a355cd6f17127c21364b41fa3de0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:35:55 -0400 Subject: [PATCH 03/20] fix(tui): increase LAT/UPTIME min column widths to prevent wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAT min 5→7 (fits '142ms' + padding), UPTIME min 5→8 (fits '100.0%' + padding). --- internal/tui/tab_sites.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 36d490e..f95b854 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -353,8 +353,8 @@ func (m Model) computeLayout() tableLayout { {"", "", 0, 0}, // NAME (dynamic) {"TYPE", "TYPE", 8, 10}, {"STATUS", "STATUS", 8, 10}, - {"LAT", "LATENCY", 5, 10}, - {"UP%", "UPTIME", 5, 8}, + {"LAT", "LATENCY", 7, 10}, + {"UP%", "UPTIME", 8, 8}, {"", "", 0, 0}, // HISTORY (dynamic) {"SSL", "SSL", 5, 7}, {"RT", "RETRIES", 5, 9}, -- 2.52.0 From c5477c7ef6e5217bc25a75589e807668a286eb48 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:39:00 -0400 Subject: [PATCH 04/20] fix(tui): size NAME column to actual content, surplus goes to sparkline Compute max monitor name length and cap NAME column to that + 4 (icon/padding). Extra space goes to HISTORY sparkline instead of dead whitespace. --- internal/tui/tab_sites.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index f95b854..b53358f 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -374,8 +374,19 @@ func (m Model) computeLayout() tableLayout { fixedMin += c.minWidth } + maxName := 0 + for _, s := range m.sites { + if n := len([]rune(s.Name)); n > maxName { + maxName = n + } + } + maxName += 4 // icon + padding + error preview room + avail := usable - fixedMin nameW := avail / 2 + if nameW > maxName { + nameW = maxName + } sparkW := avail - nameW - 2 if nameW < 13 { -- 2.52.0 From 2c78c60d08bf8b9157f55072fee55c84b97da461 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:52:24 -0400 Subject: [PATCH 05/20] fix(tui): set explicit NAME column width to match content truncation Was width=0 (auto) which let lipgloss over-allocate the column, causing visible empty space between truncated names and TYPE column. Now set to nameW explicitly so column width = truncation limit. --- internal/tui/tab_sites.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index b53358f..93eeabe 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -412,7 +412,7 @@ func (m Model) computeLayout() tableLayout { for i, c := range cols { if i == 1 { headers[i] = "NAME" - widths[i] = 0 + widths[i] = nameW continue } if i == 6 { -- 2.52.0 From 9121b795824455c398d5e24031d4f1eb4ae3b342 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 13:58:04 -0400 Subject: [PATCH 06/20] fix(tui): prevent # and SSL columns from expanding unnecessarily Set min=max for columns that don't benefit from extra width. Surplus space goes to sparkline instead. --- internal/tui/tab_sites.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 93eeabe..187aa2b 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -349,14 +349,14 @@ func (m Model) computeLayout() tableLayout { } cols := []colDef{ - {"#", "#", 4, 6}, + {"#", "#", 4, 4}, {"", "", 0, 0}, // NAME (dynamic) {"TYPE", "TYPE", 8, 10}, {"STATUS", "STATUS", 8, 10}, {"LAT", "LATENCY", 7, 10}, {"UP%", "UPTIME", 8, 8}, {"", "", 0, 0}, // HISTORY (dynamic) - {"SSL", "SSL", 5, 7}, + {"SSL", "SSL", 5, 5}, {"RT", "RETRIES", 5, 9}, } -- 2.52.0 From 2569a252ffb2f48428abaf9114f52f16687cf612 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 14:18:32 -0400 Subject: [PATCH 07/20] fix(tui): enforce column MaxWidth to prevent lipgloss redistribution lipgloss table with Width(tableWidth) redistributes surplus space across all columns. Adding MaxWidth() caps each column to its computed width. Also dump any remaining surplus into the HISTORY sparkline column. --- internal/tui/tab_sites.go | 4 ++++ internal/tui/table_helpers.go | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 187aa2b..a4efcf4 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -439,6 +439,10 @@ func (m Model) computeLayout() tableLayout { widths[i] = w } + if surplus > 0 { + widths[6] += surplus + } + return tableLayout{ nameW: nameW, sparkW: sparkW, diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 0f37e72..86a3a16 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -51,7 +51,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en style = tableSelectedStyle.Foreground(s.GetForeground()) } if col < len(colWidths) && colWidths[col] > 0 { - style = style.Width(colWidths[col]) + style = style.Width(colWidths[col]).MaxWidth(colWidths[col]) } return style } @@ -64,7 +64,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en base = tableSelectedStyle } if col < len(colWidths) && colWidths[col] > 0 { - base = base.Width(colWidths[col]) + base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) } return base }) -- 2.52.0 From 2e489cdc1aa4a74a4cf933440bf6bfc15b42ce0d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:04:38 -0400 Subject: [PATCH 08/20] fix(tui): apply Width/MaxWidth to header row cells too Header row was returning bare tableHeaderStyle with no width constraints, letting lipgloss table-level Width() inflate the # column. --- internal/tui/table_helpers.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 86a3a16..ed5397e 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -41,7 +41,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return tableHeaderStyle + h := tableHeaderStyle + if col < len(colWidths) && colWidths[col] > 0 { + h = h.Width(colWidths[col]).MaxWidth(colWidths[col]) + } + return h } isSelected := row == selectedVisual if styleOverride != nil { -- 2.52.0 From 217276ca1863349acf27fec62707274fcca95481 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:11:29 -0400 Subject: [PATCH 09/20] fix(tui): correct table border overhead calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was hardcoded to 30 — actual overhead is 2 (borders) + numCols-1 (separators) = 10 for 9 columns. The 20-char gap was being redistributed by lipgloss into columns like # making them too wide. --- internal/tui/tab_sites.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index a4efcf4..12787e7 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -360,8 +360,9 @@ func (m Model) computeLayout() tableLayout { {"RT", "RETRIES", 5, 9}, } - overhead := 30 - usable := m.termWidth - chromePadH - 2 - overhead + numCols := len(cols) + borderOverhead := 2 + (numCols - 1) // left + right border + column separators + usable := m.termWidth - chromePadH - 2 - borderOverhead if usable < 80 { usable = 80 } -- 2.52.0 From d05bbd007b1e54c6132af816a2a2980f6e623c92 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:20:12 -0400 Subject: [PATCH 10/20] feat(tui): responsive table layout for all tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared computeTableLayout() into table_helpers.go — takes column definitions with short/full headers, min/max widths, and a flex column that absorbs surplus space. All tabs now use it: - Alerts: CONFIG column is flex, NAME/TYPE/SENT expand with width - Maint: TITLE column is flex, TYPE/MONITORS/STATUS/dates expand - Nodes: NAME column is flex, REGION/LAST SEEN/VERSION expand - Users: PUBLIC KEY column is flex, USERNAME expands - Sites: uses same colDef type (keeps special dual-flex for NAME+HISTORY) Headers auto-switch short/full based on available width across all tabs. --- internal/tui/tab_alerts.go | 24 ++++++++++-- internal/tui/tab_maint.go | 21 ++++++++-- internal/tui/tab_nodes.go | 16 ++++++-- internal/tui/tab_sites.go | 25 +++++------- internal/tui/tab_users.go | 15 +++++-- internal/tui/table_helpers.go | 73 +++++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 30 deletions(-) diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 0ac4316..a8ba6a7 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -148,8 +148,26 @@ func (m Model) viewAlertsTab() string { return "\n No alert channels configured. Press [n] to add one." } + maxName := 0 + for _, a := range m.alerts { + if n := len([]rune(a.Name)); n > maxName { + maxName = n + } + } + + cols := []colDef{ + {"#", "#", 4, 4, false}, + {"", "", 3, 3, false}, + {"NAME", "NAME", 10, 20, false}, + {"TYPE", "TYPE", 10, 12, false}, + {"CONFIG", "CONFIG", 15, 40, true}, + {"SENT", "LAST SENT", 8, 12, false}, + } + headers, widths := m.computeTableLayout(cols, 0) + nameW := widths[2] + return m.renderTable( - []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"}, + headers, len(m.alerts), func(start, end int) [][]string { var rows [][]string @@ -159,7 +177,7 @@ func (m Model) viewAlertsTab() string { rows = append(rows, []string{ fmt.Sprintf("%d", i+1), fmtAlertHealth(h), - m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)), + m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), fmtAlertType(a.Type), fmtAlertConfig(struct { Type string @@ -170,7 +188,7 @@ func (m Model) viewAlertsTab() string { } return rows }, - nil, nil, + widths, nil, ) } diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 68d6141..0c061ca 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -2,10 +2,11 @@ package tui import ( "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "strconv" "time" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -92,8 +93,20 @@ func (m Model) viewMaintTab() string { return "\n No maintenance windows or incidents. Press [n] to create one." } + cols := []colDef{ + {"#", "#", 4, 4, false}, + {"TITLE", "TITLE", 12, 28, true}, + {"TYPE", "TYPE", 10, 14, false}, + {"MON", "MONITORS", 10, 20, false}, + {"STATUS", "STATUS", 8, 12, false}, + {"START", "STARTED", 10, 16, false}, + {"ENDS", "ENDS", 10, 16, false}, + } + headers, widths := m.computeTableLayout(cols, 0) + titleW := widths[1] + return m.renderTable( - []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}, + headers, len(m.maintenanceWindows), func(start, end int) [][]string { var rows [][]string @@ -102,7 +115,7 @@ func (m Model) viewMaintTab() string { mw := m.maintenanceWindows[i] rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, 24)), + m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), fmtMaintType(mw.Type), fmtMaintMonitor(mw.MonitorID, allSites), fmtMaintStatus(mw), @@ -112,7 +125,7 @@ func (m Model) viewMaintTab() string { } return rows }, - []int{6, 0, 14, 20, 12, 16, 16}, + widths, nil, ) } diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index a4a049c..6946e6d 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -10,16 +10,24 @@ func (m Model) viewNodesTab() string { return "\n No probe nodes connected." } - colWidths := []int{0, 12, 20, 10, 8} + cols := []colDef{ + {"NAME", "NAME", 12, 24, true}, + {"REGION", "REGION", 8, 14, false}, + {"SEEN", "LAST SEEN", 8, 20, false}, + {"VER", "VERSION", 8, 12, false}, + {"STATUS", "STATUS", 8, 10, false}, + } + headers, widths := m.computeTableLayout(cols, 0) + nameW := widths[0] return m.renderTable( - []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}, + headers, len(m.nodes), func(start, end int) [][]string { var rows [][]string for i := start; i < end; i++ { node := m.nodes[i] - name := limitStr(node.Name, 20) + name := limitStr(node.Name, nameW-2) if name == "" { name = node.ID } @@ -37,7 +45,7 @@ func (m Model) viewNodesTab() string { } return rows }, - colWidths, + widths, nil, ) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 12787e7..fecba98 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -341,23 +341,16 @@ type tableLayout struct { } func (m Model) computeLayout() tableLayout { - type colDef struct { - short string - full string - minWidth int - maxWidth int - } - cols := []colDef{ - {"#", "#", 4, 4}, - {"", "", 0, 0}, // NAME (dynamic) - {"TYPE", "TYPE", 8, 10}, - {"STATUS", "STATUS", 8, 10}, - {"LAT", "LATENCY", 7, 10}, - {"UP%", "UPTIME", 8, 8}, - {"", "", 0, 0}, // HISTORY (dynamic) - {"SSL", "SSL", 5, 5}, - {"RT", "RETRIES", 5, 9}, + {"#", "#", 4, 4, false}, + {"", "", 0, 0, false}, // NAME (special) + {"TYPE", "TYPE", 8, 10, false}, + {"STATUS", "STATUS", 8, 10, false}, + {"LAT", "LATENCY", 7, 10, false}, + {"UP%", "UPTIME", 8, 8, false}, + {"", "", 0, 0, false}, // HISTORY (special) + {"SSL", "SSL", 5, 5, false}, + {"RT", "RETRIES", 5, 9, false}, } numCols := len(cols) diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 4793933..10e4108 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -32,8 +32,17 @@ func (m Model) viewUsersTab() string { return "\n No users configured. Press [n] to add one." } + cols := []colDef{ + {"#", "#", 4, 4, false}, + {"USER", "USERNAME", 10, 18, false}, + {"ROLE", "ROLE", 8, 10, false}, + {"KEY", "PUBLIC KEY", 20, 60, true}, + } + headers, widths := m.computeTableLayout(cols, 0) + userW := widths[1] + return m.renderTable( - []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}, + headers, len(m.users), func(start, end int) [][]string { var rows [][]string @@ -41,14 +50,14 @@ func (m Model) viewUsersTab() string { u := m.users[i] rows = append(rows, []string{ fmt.Sprintf("%d", i+1), - m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), + m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)), fmtRole(u.Role), fmtKey(u.PublicKey), }) } return rows }, - nil, nil, + widths, nil, ) } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index ed5397e..371ecee 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -15,6 +15,79 @@ var ( type StyleOverride func(row, col int) *lipgloss.Style +type colDef struct { + short string + full string + minWidth int + maxWidth int + flex bool +} + +func (m Model) computeTableLayout(cols []colDef, maxContentWidth int) ([]string, []int) { + numCols := len(cols) + borderOverhead := 2 + (numCols - 1) + usable := m.termWidth - chromePadH - 2 - borderOverhead + if usable < 40 { + usable = 40 + } + + fixedMin := 0 + flexIdx := -1 + for i, c := range cols { + if c.flex { + flexIdx = i + continue + } + fixedMin += c.minWidth + } + + flexW := usable - fixedMin + if maxContentWidth > 0 && flexW > maxContentWidth { + flexW = maxContentWidth + } + if flexW < 8 { + flexW = 8 + } + + surplus := usable - fixedMin - flexW + if surplus < 0 { + surplus = 0 + } + + headers := make([]string, numCols) + widths := make([]int, numCols) + for i, c := range cols { + if c.flex { + headers[i] = c.full + widths[i] = flexW + continue + } + + w := c.minWidth + expand := c.maxWidth - c.minWidth + if surplus >= expand { + w = c.maxWidth + surplus -= expand + } else if surplus > 0 { + w += surplus + surplus = 0 + } + + if w >= len(c.full)+2 { + headers[i] = c.full + } else { + headers[i] = c.short + } + widths[i] = w + } + + if surplus > 0 && flexIdx >= 0 { + widths[flexIdx] += surplus + } + + return headers, widths +} + func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string { if items == 0 { return "" -- 2.52.0 From eb61a0dd3c68e8ebe8c9764273866883558a2e90 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:22:18 -0400 Subject: [PATCH 11/20] fix(tui): increase maint tab min column widths for TYPE/MONITORS/STATUS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TYPE min 10→13 (fits 'maintenance'), MONITORS min 10→13, STATUS min 8→11 (fits 'SCHEDULED'). Prevents word wrapping. --- internal/tui/tab_maint.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 0c061ca..0991cd4 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -96,9 +96,9 @@ func (m Model) viewMaintTab() string { cols := []colDef{ {"#", "#", 4, 4, false}, {"TITLE", "TITLE", 12, 28, true}, - {"TYPE", "TYPE", 10, 14, false}, - {"MON", "MONITORS", 10, 20, false}, - {"STATUS", "STATUS", 8, 12, false}, + {"TYPE", "TYPE", 13, 14, false}, + {"MON", "MONITORS", 13, 20, false}, + {"STATUS", "STATUS", 11, 12, false}, {"START", "STARTED", 10, 16, false}, {"ENDS", "ENDS", 10, 16, false}, } -- 2.52.0 From 251c723fbd58a543084f2cfce624749cd5b0311d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:24:25 -0400 Subject: [PATCH 12/20] =?UTF-8?q?fix(tui):=20maint=20tab=20=E2=80=94=20bum?= =?UTF-8?q?p=20MONITORS/STARTED/ENDS=20min=20widths,=20respect=20column=20?= =?UTF-8?q?width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MONITORS min 13→15 (monitor names can be long, truncate to column width). STARTED/ENDS min 10→14 (fits '18:30 May 28' = 12 chars + padding). fmtMaintMonitorW truncates name to actual column width. --- internal/tui/tab_maint.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 0991cd4..b12c394 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -42,12 +42,16 @@ func fmtMaintType(t string) string { } func fmtMaintMonitor(monitorID int, sites []models.Site) string { + return fmtMaintMonitorW(monitorID, sites, 18) +} + +func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { if monitorID == 0 { return "All" } for _, s := range sites { if s.ID == monitorID { - return limitStr(s.Name, 18) + return limitStr(s.Name, maxW) } } return fmt.Sprintf("#%d", monitorID) @@ -97,13 +101,14 @@ func (m Model) viewMaintTab() string { {"#", "#", 4, 4, false}, {"TITLE", "TITLE", 12, 28, true}, {"TYPE", "TYPE", 13, 14, false}, - {"MON", "MONITORS", 13, 20, false}, + {"MON", "MONITORS", 15, 22, false}, {"STATUS", "STATUS", 11, 12, false}, - {"START", "STARTED", 10, 16, false}, - {"ENDS", "ENDS", 10, 16, false}, + {"START", "STARTED", 14, 16, false}, + {"ENDS", "ENDS", 14, 16, false}, } headers, widths := m.computeTableLayout(cols, 0) titleW := widths[1] + monW := widths[3] return m.renderTable( headers, @@ -117,7 +122,7 @@ func (m Model) viewMaintTab() string { strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), fmtMaintType(mw.Type), - fmtMaintMonitor(mw.MonitorID, allSites), + fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), fmtMaintStatus(mw), fmtMaintTime(mw.StartTime), fmtMaintTime(mw.EndTime), -- 2.52.0 From d9dcd58b663963c7c8f7a90d1ebce347a4c620df Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:31:01 -0400 Subject: [PATCH 13/20] fix(tui): maint tab min widths fit 80-column terminals Reduce minimums so fixedMin=51 (was 71). At narrow: compact headers (ST, MON). At wide: full headers (STATUS, MONITORS, STARTED) with expanded widths. --- internal/tui/tab_maint.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index b12c394..e09383c 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -99,12 +99,12 @@ func (m Model) viewMaintTab() string { cols := []colDef{ {"#", "#", 4, 4, false}, - {"TITLE", "TITLE", 12, 28, true}, + {"TITLE", "TITLE", 10, 28, true}, {"TYPE", "TYPE", 13, 14, false}, - {"MON", "MONITORS", 15, 22, false}, - {"STATUS", "STATUS", 11, 12, false}, - {"START", "STARTED", 14, 16, false}, - {"ENDS", "ENDS", 14, 16, false}, + {"MON", "MONITORS", 10, 22, false}, + {"ST", "STATUS", 8, 12, false}, + {"START", "STARTED", 8, 16, false}, + {"ENDS", "ENDS", 8, 16, false}, } headers, widths := m.computeTableLayout(cols, 0) titleW := widths[1] -- 2.52.0 From a84f4894f804ba7c6ba0450f2724f60730a3fea8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:40:18 -0400 Subject: [PATCH 14/20] =?UTF-8?q?fix(tui):=20maint=20tab=20=E2=80=94=20MON?= =?UTF-8?q?ITORS=20is=20flex,=20dates=20get=20room,=20time=20adapts=20to?= =?UTF-8?q?=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TITLE was flex eating all space while dates/monitors squeezed. Flipped: MONITORS is now flex, TITLE fixed (12-24), dates min 14, STATUS min 11. fmtMaintTime uses compact format (Jan 02) at narrow widths. --- internal/tui/tab_maint.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index e09383c..3eb216f 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -57,7 +57,7 @@ func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { return fmt.Sprintf("#%d", monitorID) } -func fmtMaintTime(t time.Time) string { +func fmtMaintTime(t time.Time, colW int) string { if t.IsZero() { return subtleStyle.Render("—") } @@ -65,7 +65,10 @@ func fmtMaintTime(t time.Time) string { if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return t.Format("15:04") } - return t.Format("15:04 Jan 02") + if colW >= 14 { + return t.Format("15:04 Jan 02") + } + return t.Format("Jan 02") } func (m Model) isMonitorInMaintenance(monitorID int) bool { @@ -99,16 +102,17 @@ func (m Model) viewMaintTab() string { cols := []colDef{ {"#", "#", 4, 4, false}, - {"TITLE", "TITLE", 10, 28, true}, + {"TITLE", "TITLE", 12, 24, false}, {"TYPE", "TYPE", 13, 14, false}, - {"MON", "MONITORS", 10, 22, false}, - {"ST", "STATUS", 8, 12, false}, - {"START", "STARTED", 8, 16, false}, - {"ENDS", "ENDS", 8, 16, false}, + {"MON", "MONITORS", 14, 22, true}, + {"ST", "STATUS", 11, 12, false}, + {"START", "STARTED", 14, 16, false}, + {"ENDS", "ENDS", 14, 16, false}, } headers, widths := m.computeTableLayout(cols, 0) titleW := widths[1] monW := widths[3] + timeW := widths[5] return m.renderTable( headers, @@ -124,8 +128,8 @@ func (m Model) viewMaintTab() string { fmtMaintType(mw.Type), fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), fmtMaintStatus(mw), - fmtMaintTime(mw.StartTime), - fmtMaintTime(mw.EndTime), + fmtMaintTime(mw.StartTime, timeW), + fmtMaintTime(mw.EndTime, timeW), }) } return rows -- 2.52.0 From 5401266e8378487dd16d96aa8ef7cad7c09d266c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:50:23 -0400 Subject: [PATCH 15/20] refactor(tui): two-tier responsive table layout (compact/wide at 120 cols) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace continuous surplus distribution with two fixed layouts per table. Breakpoint at 120 columns — matches how btop/k9s do it. Compact (<120): short headers (LAT, UP%, RT, ST, MON, SENT, VER), tight fixed widths, no surplus guessing. Wide (≥120): full headers (LATENCY, UPTIME, RETRIES, STATUS, MONITORS, LAST SENT, VERSION), generous widths. Sites tab keeps content-aware NAME sizing + sparkline flex. All other tabs (Alerts, Maint, Nodes, Users) use simple fixed tiers. Removed old computeTableLayout/colDef/tierCol/pickTier — no longer needed. --- internal/tui/tab_alerts.go | 28 ++++------ internal/tui/tab_maint.go | 17 +++---- internal/tui/tab_nodes.go | 15 +++--- internal/tui/tab_sites.go | 96 ++++++++++------------------------- internal/tui/tab_users.go | 14 ++--- internal/tui/table_helpers.go | 73 ++------------------------ 6 files changed, 64 insertions(+), 179 deletions(-) diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index a8ba6a7..a9d6d2b 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -148,23 +148,17 @@ func (m Model) viewAlertsTab() string { return "\n No alert channels configured. Press [n] to add one." } - maxName := 0 - for _, a := range m.alerts { - if n := len([]rune(a.Name)); n > maxName { - maxName = n - } + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"} + widths = []int{4, 3, 18, 12, 40, 12} + } else { + headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "SENT"} + widths = []int{4, 3, 14, 10, 24, 8} } - - cols := []colDef{ - {"#", "#", 4, 4, false}, - {"", "", 3, 3, false}, - {"NAME", "NAME", 10, 20, false}, - {"TYPE", "TYPE", 10, 12, false}, - {"CONFIG", "CONFIG", 15, 40, true}, - {"SENT", "LAST SENT", 8, 12, false}, - } - headers, widths := m.computeTableLayout(cols, 0) nameW := widths[2] + cfgW := widths[4] return m.renderTable( headers, @@ -179,10 +173,10 @@ func (m Model) viewAlertsTab() string { fmtAlertHealth(h), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), fmtAlertType(a.Type), - fmtAlertConfig(struct { + limitStr(fmtAlertConfig(struct { Type string Settings map[string]string - }{a.Type, a.Settings}), + }{a.Type, a.Settings}), cfgW-2), fmtAlertLastSent(h), }) } diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 3eb216f..8bf5541 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -100,16 +100,15 @@ func (m Model) viewMaintTab() string { return "\n No maintenance windows or incidents. Press [n] to create one." } - cols := []colDef{ - {"#", "#", 4, 4, false}, - {"TITLE", "TITLE", 12, 24, false}, - {"TYPE", "TYPE", 13, 14, false}, - {"MON", "MONITORS", 14, 22, true}, - {"ST", "STATUS", 11, 12, false}, - {"START", "STARTED", 14, 16, false}, - {"ENDS", "ENDS", 14, 16, false}, + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"} + widths = []int{4, 24, 14, 22, 12, 16, 16} + } else { + headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"} + widths = []int{4, 14, 13, 14, 11, 14, 14} } - headers, widths := m.computeTableLayout(cols, 0) titleW := widths[1] monW := widths[3] timeW := widths[5] diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index 6946e6d..3ddb79d 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -10,14 +10,15 @@ func (m Model) viewNodesTab() string { return "\n No probe nodes connected." } - cols := []colDef{ - {"NAME", "NAME", 12, 24, true}, - {"REGION", "REGION", 8, 14, false}, - {"SEEN", "LAST SEEN", 8, 20, false}, - {"VER", "VERSION", 8, 12, false}, - {"STATUS", "STATUS", 8, 10, false}, + var headers []string + var widths []int + if m.isWide() { + headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"} + widths = []int{24, 14, 16, 12, 10} + } else { + headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"} + widths = []int{16, 10, 10, 8, 8} } - headers, widths := m.computeTableLayout(cols, 0) nameW := widths[0] return m.renderTable( diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index fecba98..20befa5 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -341,31 +341,29 @@ type tableLayout struct { } func (m Model) computeLayout() tableLayout { - cols := []colDef{ - {"#", "#", 4, 4, false}, - {"", "", 0, 0, false}, // NAME (special) - {"TYPE", "TYPE", 8, 10, false}, - {"STATUS", "STATUS", 8, 10, false}, - {"LAT", "LATENCY", 7, 10, false}, - {"UP%", "UPTIME", 8, 8, false}, - {"", "", 0, 0, false}, // HISTORY (special) - {"SSL", "SSL", 5, 5, false}, - {"RT", "RETRIES", 5, 9, false}, + wide := m.isWide() + + var fixed int + var headers []string + var widths []int + + if wide { + // # NAME TYPE STATUS LATENCY UPTIME HISTORY SSL RETRIES + headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"} + widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9} + fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9 + } else { + // # NAME TYPE STATUS LAT UP% HISTORY SSL RT + headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"} + widths = []int{4, 0, 8, 8, 7, 7, 0, 5, 5} + fixed = 4 + 8 + 8 + 7 + 7 + 5 + 5 } - numCols := len(cols) - borderOverhead := 2 + (numCols - 1) // left + right border + column separators - usable := m.termWidth - chromePadH - 2 - borderOverhead - if usable < 80 { - usable = 80 - } - - fixedMin := 0 - for i, c := range cols { - if i == 1 || i == 6 { - continue - } - fixedMin += c.minWidth + numCols := len(headers) + borderOverhead := 2 + (numCols - 1) + avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed + if avail < 20 { + avail = 20 } maxName := 0 @@ -374,68 +372,26 @@ func (m Model) computeLayout() tableLayout { maxName = n } } - maxName += 4 // icon + padding + error preview room + maxName += 4 - avail := usable - fixedMin nameW := avail / 2 if nameW > maxName { nameW = maxName } - sparkW := avail - nameW - 2 - if nameW < 13 { nameW = 13 } if nameW > 40 { nameW = 40 } + + sparkW := avail - nameW if sparkW < 10 { sparkW = 10 } - if sparkW > 60 { - sparkW = 60 - } - surplus := usable - fixedMin - nameW - sparkW - 2 - if surplus < 0 { - surplus = 0 - } - - headers := make([]string, len(cols)) - widths := make([]int, len(cols)) - for i, c := range cols { - if i == 1 { - headers[i] = "NAME" - widths[i] = nameW - continue - } - if i == 6 { - headers[i] = "HISTORY" - widths[i] = sparkW + 2 - continue - } - - w := c.minWidth - expand := c.maxWidth - c.minWidth - if surplus >= expand { - w = c.maxWidth - surplus -= expand - } else if surplus > 0 { - w += surplus - surplus = 0 - } - - if w >= len(c.full)+2 { - headers[i] = c.full - } else { - headers[i] = c.short - } - widths[i] = w - } - - if surplus > 0 { - widths[6] += surplus - } + widths[1] = nameW + widths[6] = sparkW return tableLayout{ nameW: nameW, diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 10e4108..a02badd 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -32,13 +32,15 @@ func (m Model) viewUsersTab() string { return "\n No users configured. Press [n] to add one." } - cols := []colDef{ - {"#", "#", 4, 4, false}, - {"USER", "USERNAME", 10, 18, false}, - {"ROLE", "ROLE", 8, 10, false}, - {"KEY", "PUBLIC KEY", 20, 60, true}, + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"} + widths = []int{4, 18, 10, 50} + } else { + headers = []string{"#", "USER", "ROLE", "KEY"} + widths = []int{4, 14, 8, 30} } - headers, widths := m.computeTableLayout(cols, 0) userW := widths[1] return m.renderTable( diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 371ecee..175ca30 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -15,77 +15,10 @@ var ( type StyleOverride func(row, col int) *lipgloss.Style -type colDef struct { - short string - full string - minWidth int - maxWidth int - flex bool -} +const wideBreakpoint = 120 -func (m Model) computeTableLayout(cols []colDef, maxContentWidth int) ([]string, []int) { - numCols := len(cols) - borderOverhead := 2 + (numCols - 1) - usable := m.termWidth - chromePadH - 2 - borderOverhead - if usable < 40 { - usable = 40 - } - - fixedMin := 0 - flexIdx := -1 - for i, c := range cols { - if c.flex { - flexIdx = i - continue - } - fixedMin += c.minWidth - } - - flexW := usable - fixedMin - if maxContentWidth > 0 && flexW > maxContentWidth { - flexW = maxContentWidth - } - if flexW < 8 { - flexW = 8 - } - - surplus := usable - fixedMin - flexW - if surplus < 0 { - surplus = 0 - } - - headers := make([]string, numCols) - widths := make([]int, numCols) - for i, c := range cols { - if c.flex { - headers[i] = c.full - widths[i] = flexW - continue - } - - w := c.minWidth - expand := c.maxWidth - c.minWidth - if surplus >= expand { - w = c.maxWidth - surplus -= expand - } else if surplus > 0 { - w += surplus - surplus = 0 - } - - if w >= len(c.full)+2 { - headers[i] = c.full - } else { - headers[i] = c.short - } - widths[i] = w - } - - if surplus > 0 && flexIdx >= 0 { - widths[flexIdx] += surplus - } - - return headers, widths +func (m Model) isWide() bool { + return m.termWidth >= wideBreakpoint } func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string { -- 2.52.0 From c487a8eb26bef24673017a67d840c4d9bbb64cde Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 15:57:33 -0400 Subject: [PATCH 16/20] fix(tui): compute table Width from column sum, not terminal width Table Width was set to terminal width, forcing lipgloss to redistribute surplus space into columns like #. Now computed from sum of column widths + border overhead. Table is exactly as wide as needed, capped at terminal. --- internal/tui/table_helpers.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 175ca30..8b9f2d4 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -34,7 +34,16 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en selectedVisual := m.cursor - m.tableOffset rows := buildRows(m.tableOffset, end) - tableWidth := m.termWidth - chromePadH - 2 + colTotal := 0 + for _, w := range colWidths { + colTotal += w + } + borderOverhead := 2 + len(colWidths) - 1 + tableWidth := colTotal + borderOverhead + maxWidth := m.termWidth - chromePadH - 2 + if tableWidth > maxWidth { + tableWidth = maxWidth + } if tableWidth < 40 { tableWidth = 40 } -- 2.52.0 From fa96c5fd3fb887a8fae239ff8c0fc8b61dcaee0d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 16:01:44 -0400 Subject: [PATCH 17/20] fix(tui): render sparkline 2 chars narrower than column to prevent wrapping Cell padding inside the table causes sparkline content at full column width to wrap. Subtract 2 from sparkWidth for content rendering. --- internal/tui/tab_sites.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 20befa5..04c2dbe 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -418,7 +418,10 @@ func (m Model) viewSitesTab() string { layout := m.computeLayout() nameW := layout.nameW - sparkWidth := layout.sparkW + sparkWidth := layout.sparkW - 2 + if sparkWidth < 8 { + sparkWidth = 8 + } var groupRows map[int]bool return m.renderTable( -- 2.52.0 From 1c29758ca27f10e1915371b8afd250dde9637f4c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 16:04:51 -0400 Subject: [PATCH 18/20] =?UTF-8?q?fix(tui):=20bump=20compact=20UPTIME=20col?= =?UTF-8?q?umn=207=E2=86=928=20to=20fit=20100.0%=20with=20cell=20padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/tab_sites.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 04c2dbe..f9e5bd2 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -355,8 +355,8 @@ func (m Model) computeLayout() tableLayout { } else { // # NAME TYPE STATUS LAT UP% HISTORY SSL RT headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"} - widths = []int{4, 0, 8, 8, 7, 7, 0, 5, 5} - fixed = 4 + 8 + 8 + 7 + 7 + 5 + 5 + widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5} + fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5 } numCols := len(headers) -- 2.52.0 From ad14daf6ae1d7ef6e0d0f0fc316b5986e54bce9e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 16:07:58 -0400 Subject: [PATCH 19/20] fix(tui): account for cell padding in NAME column content limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group icons, tree prefixes (├/└), and regular names all need 2 extra chars margin for cell padding. Reduces truncation limits by 2 across all name rendering paths. --- internal/tui/tab_sites.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index f9e5bd2..13f45a9 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -438,7 +438,7 @@ func (m Model) viewSitesTab() string { icon := typeIcon("group", m.collapsed[site.ID]) rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)), + m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), "group", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("—"), @@ -456,14 +456,14 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, nameW-2) + name = prefix + " " + limitStr(name, nameW-4) } else { - name = limitStr(name, nameW) + name = limitStr(name, nameW-2) } if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { nameLen := len([]rune(name)) - errSpace := nameW - nameLen - 1 + errSpace := nameW - nameLen - 3 if errSpace > 10 { name = name + " " + subtleStyle.Render(limitStr(site.LastError, errSpace)) } -- 2.52.0 From 4e7018ab28481fe7cadca07211625998ba85b7c2 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 28 May 2026 16:33:00 -0400 Subject: [PATCH 20/20] fix(lint): remove unused fmtMaintMonitor function --- internal/tui/tab_maint.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 8bf5541..0f11842 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -41,10 +41,6 @@ func fmtMaintType(t string) string { return maintStyle.Render("maintenance") } -func fmtMaintMonitor(monitorID int, sites []models.Site) string { - return fmtMaintMonitorW(monitorID, sites, 18) -} - func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { if monitorID == 0 { return "All" -- 2.52.0