From 88e4f0ed695850bb597016f33cf952379277ebdf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 22 May 2026 20:26:49 -0400 Subject: [PATCH] fix(tui): group selection highlight, layout constants, group history graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group rows now show selection background when navigated to. Layout chrome extracted to named constants to prevent viewport drift. Groups display aggregate history as dot sparkline (●) distinct from site bar sparklines, with uptime computed from active children only. Paused and maintenance children excluded from group aggregates. --- internal/tui/tab_sites.go | 93 +++++++++++++++++++++++++++++++++-- internal/tui/table_helpers.go | 15 ++++-- internal/tui/tui.go | 19 +++++-- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 6a978bd..f611118 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) string { return sb.String() } +func (m Model) groupSparkline(groupID int, width int) string { + allSites := m.engine.GetAllSites() + var childStatuses [][]bool + for _, s := range allSites { + if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) { + hist, _ := m.engine.GetHistory(s.ID) + if len(hist.Statuses) > 0 { + childStatuses = append(childStatuses, hist.Statuses) + } + } + } + + if len(childStatuses) == 0 { + return subtleStyle.Render(strings.Repeat("·", width)) + } + + maxLen := 0 + for _, s := range childStatuses { + if len(s) > maxLen { + maxLen = len(s) + } + } + if maxLen > width { + maxLen = width + } + + aggregated := make([]bool, maxLen) + for i := 0; i < maxLen; i++ { + allUp := true + for _, statuses := range childStatuses { + idx := len(statuses) - maxLen + i + if idx >= 0 && !statuses[idx] { + allUp = false + break + } + } + aggregated[i] = allUp + } + + var sb strings.Builder + if remaining := width - len(aggregated); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } + for _, up := range aggregated { + if up { + sb.WriteString(specialStyle.Render("●")) + } else { + sb.WriteString(dangerStyle.Render("●")) + } + } + return sb.String() +} + +func (m Model) groupUptime(groupID int) string { + allSites := m.engine.GetAllSites() + var allStatuses [][]bool + for _, s := range allSites { + if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) { + hist, _ := m.engine.GetHistory(s.ID) + if len(hist.Statuses) > 0 { + allStatuses = append(allStatuses, hist.Statuses) + } + } + } + if len(allStatuses) == 0 { + return subtleStyle.Render("—") + } + total, up := 0, 0 + for _, statuses := range allStatuses { + for _, s := range statuses { + total++ + if s { + up++ + } + } + } + return fmtUptime(func() []bool { + out := make([]bool, total) + idx := 0 + for _, statuses := range allStatuses { + copy(out[idx:], statuses) + idx += len(statuses) + } + return out + }()) +} + func fmtLatency(d time.Duration) string { ms := d.Milliseconds() if ms == 0 { @@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string { 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 - 6 - fixed - overhead + avail := m.termWidth - chromePadH - 2 - fixed - overhead if avail < 30 { avail = 30 } @@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string { "group", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("—"), - subtleStyle.Render("—"), - subtleStyle.Render(strings.Repeat("·", sparkWidth)), + m.groupUptime(site.ID), + m.groupSparkline(site.ID, sparkWidth), subtleStyle.Render("-"), subtleStyle.Render("—"), }) diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index be8719e..6cd01ef 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -38,7 +38,7 @@ 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 - 6 + tableWidth := m.termWidth - chromePadH - 2 if tableWidth < 40 { tableWidth = 40 } @@ -53,16 +53,21 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en if row == table.HeaderRow { return tableHeaderStyle } + isSelected := row == selectedVisual if styleOverride != nil { if s := styleOverride(row, col); s != nil { - if col < len(colWidths) && colWidths[col] > 0 { - return s.Width(colWidths[col]) + style := *s + if isSelected { + style = tableSelectedStyle.Foreground(s.GetForeground()) } - return *s + if col < len(colWidths) && colWidths[col] > 0 { + style = style.Width(colWidths[col]) + } + return style } } base := tableCellStyle - if row == selectedVisual { + if isSelected { base = tableSelectedStyle } if col < len(colWidths) && colWidths[col] > 0 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1a2e9b6..8f7283a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -31,6 +31,16 @@ var ( var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +const ( + chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom + chromePadH = 4 // outer Padding(1,2): 2 left + 2 right + chromeHeader = 1 // tab bar line + chromeGaps = 2 // "\n" separators: before content + before footer + chromeFooter = 2 // footer: "\n" prefix + text line + chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines) + chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable +) + type sessionState int const ( @@ -198,17 +208,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.termWidth = msg.Width m.termHeight = msg.Height - // Chrome: 1 top pad + 1 tabs + 2 newlines + 3 table borders + 1 table header + 1 footer + 1 bottom pad = 10 - chrome := 10 - if m.filterText != "" { + chrome := chromeBase + if m.filterMode || m.filterText != "" { chrome++ } m.maxTableRows = msg.Height - chrome if m.maxTableRows < 1 { m.maxTableRows = 1 } - m.logViewport.Width = msg.Width - 4 - m.logViewport.Height = msg.Height - 8 + m.logViewport.Width = msg.Width - chromePadH + m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) return m, tea.ClearScreen case time.Time: