From 88e4f0ed695850bb597016f33cf952379277ebdf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 22 May 2026 20:26:49 -0400 Subject: [PATCH 1/3] 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: From e84b64f8edc368293e682991f5484a73e22d1e18 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 22 May 2026 20:53:23 -0400 Subject: [PATCH 2/3] feat(tui): zebra striping, detail breadcrumb, sparkline stats, collapse persistence Add alternating row backgrounds for easier table scanning. Detail panel now shows breadcrumb path (Sites > Group > Name) and min/avg/max latency stats below the sparkline. Group collapse state persists across restarts via new preferences table in both SQLite and Postgres. --- internal/metrics/prometheus_test.go | 2 ++ internal/store/postgres.go | 4 +++ internal/store/sqlite.go | 4 +++ internal/store/sqlstore.go | 18 ++++++++++++ internal/store/store.go | 4 +++ internal/tui/tab_sites.go | 44 +++++++++++++++++++++++++++-- internal/tui/table_helpers.go | 7 +++++ internal/tui/tui.go | 32 ++++++++++++++++++++- 8 files changed, 112 insertions(+), 3 deletions(-) diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 60167bc..d16723b 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -62,6 +62,8 @@ func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { retur func (m *mockStore) EndMaintenanceWindow(int) error { return nil } func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil } func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil } +func (m *mockStore) GetPreference(string) (string, error) { return "", nil } +func (m *mockStore) SetPreference(string, string) error { return nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/store/postgres.go b/internal/store/postgres.go index f2a59e8..f119a37 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -67,6 +67,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string { created_by TEXT DEFAULT '', created_at TIMESTAMP DEFAULT NOW() )`, + `CREATE TABLE IF NOT EXISTS preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 30fd07e..5cab498 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -67,6 +67,10 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { created_by TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 7db980c..ebb0aa1 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -441,6 +441,24 @@ func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) { return count > 0, nil } +func (s *SQLStore) GetPreference(key string) (string, error) { + var value string + err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value) + if err != nil { + return "", err + } + return value, nil +} + +func (s *SQLStore) SetPreference(key, value string) error { + if s.dollar { + _, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value) + return err + } + _, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value) + return err +} + func (s *SQLStore) ExportData() (models.Backup, error) { sites, err := s.GetSites() if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 3c59e0c..d83ca72 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -57,6 +57,10 @@ type Store interface { DeleteMaintenanceWindow(id int) error IsMonitorInMaintenance(monitorID int) (bool, error) + // Preferences + GetPreference(key string) (string, error) + SetPreference(key, value string) error + // Backup & Restore ExportData() (models.Backup, error) ImportData(data models.Backup) error diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index f611118..2ece926 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -706,8 +706,19 @@ func (m Model) viewDetailPanel() string { var b strings.Builder - title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) - b.WriteString(title + "\n\n") + var breadcrumb string + if site.ParentID > 0 { + for _, s := range m.sites { + if s.ID == site.ParentID { + breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name) + break + } + } + } + if breadcrumb == "" { + breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name) + } + b.WriteString(breadcrumb + "\n\n") row := func(label, value string) { b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) @@ -777,8 +788,37 @@ func (m Model) viewDetailPanel() string { const sparkWidth = 40 if site.Type == "push" { b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) + if len(hist.Statuses) > 0 { + up := 0 + for _, s := range hist.Statuses { + if s { + up++ + } + } + b.WriteString(fmt.Sprintf("\n %s %d/%d checks up", + subtleStyle.Render("Heartbeats"), + up, len(hist.Statuses))) + } } else { b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) + if len(hist.Latencies) > 0 { + minL, maxL := hist.Latencies[0], hist.Latencies[0] + var total time.Duration + for _, l := range hist.Latencies { + total += l + if l < minL { + minL = l + } + if l > maxL { + maxL = l + } + } + avg := total / time.Duration(len(hist.Latencies)) + b.WriteString(fmt.Sprintf("\n %s %dms %s %dms %s %dms", + subtleStyle.Render("Min"), minL.Milliseconds(), + subtleStyle.Render("Avg"), avg.Milliseconds(), + subtleStyle.Render("Max"), maxL.Milliseconds())) + } } b.WriteString("\n\n") diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 6cd01ef..7c4e654 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -21,6 +21,10 @@ var ( tableBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) + + tableZebraStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#1a1a2e")) ) type StyleOverride func(row, col int) *lipgloss.Style @@ -67,6 +71,9 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en } } base := tableCellStyle + if row%2 == 1 { + base = tableZebraStyle + } if isSelected { base = tableSelectedStyle } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 8f7283a..5ad3a06 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "fmt" "go-upkeep/internal/models" "go-upkeep/internal/monitor" @@ -105,6 +106,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { vpLogs.SetContent("Waiting for logs...") z := zone.New() spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) + collapsed := loadCollapsed(s) return Model{ state: stateDashboard, logViewport: vpLogs, @@ -114,10 +116,37 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { engine: eng, zones: z, pulseSpring: spring, - collapsed: make(map[int]bool), + collapsed: collapsed, } } +func loadCollapsed(s store.Store) map[int]bool { + m := make(map[int]bool) + raw, err := s.GetPreference("collapsed_groups") + if err != nil || raw == "" { + return m + } + var ids []int + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return m + } + for _, id := range ids { + m[id] = true + } + return m +} + +func saveCollapsed(s store.Store, collapsed map[int]bool) { + var ids []int + for id, v := range collapsed { + if v { + ids = append(ids, id) + } + } + data, _ := json.Marshal(ids) + _ = s.SetPreference("collapsed_groups", string(data)) +} + func (m Model) Init() tea.Cmd { return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })) } @@ -401,6 +430,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { gid := m.sites[m.cursor].ID m.collapsed[gid] = !m.collapsed[gid] + saveCollapsed(m.store, m.collapsed) m.refreshData() } case "p": From fb11e9ba8590dfcb9ebca9d3d5070b6290cf6e91 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 23 May 2026 11:01:34 -0400 Subject: [PATCH 3/3] fix(tui): stable monitor count and universal group icons Site count in tab label and footer now reflects total monitors (excluding groups) regardless of collapse state. Down count also computed from all sites so collapsed groups with down children still surface in the badge. Replaced Nerd Font folder glyphs with standard Unicode triangles for cross-font compatibility. --- internal/tui/tab_sites.go | 4 ++-- internal/tui/tui.go | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 2ece926..ac89ce1 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -29,9 +29,9 @@ func typeIcon(siteType string, collapsed bool) string { return "◆" case "group": if collapsed { - return "" + return "▶" } - return "" + return "▼" default: return "·" } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 5ad3a06..a992ca6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -764,8 +764,14 @@ func (m Model) View() string { } func (m Model) viewDashboard() string { + allSites := m.engine.GetAllSites() + totalMonitors := 0 downCount := 0 - for _, s := range m.sites { + for _, s := range allSites { + if s.Type == "group" { + continue + } + totalMonitors++ if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { downCount++ } @@ -780,8 +786,8 @@ func (m Model) viewDashboard() string { var sitesLabel string if downCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) - } else if len(m.sites) > 0 { - sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) + } else if totalMonitors > 0 { + sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors) } else { sitesLabel = "Sites" } @@ -845,12 +851,12 @@ func (m Model) viewDashboard() string { } } - upCount := len(m.sites) - downCount + upCount := totalMonitors - downCount var upStr string if downCount > 0 { - upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors)) } else { - upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors)) } statusParts := []string{upStr} if len(m.nodes) > 0 {