From e84b64f8edc368293e682991f5484a73e22d1e18 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 22 May 2026 20:53:23 -0400 Subject: [PATCH] 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":