From 22c60221215ccb437ad5d6f7b849b92773aa0ec3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:28:37 -0400 Subject: [PATCH 1/7] feat(tui): DOWN-first sort, health pulse, and site filter - DOWN/SSL EXP monitors float to top of sites list - Pulse indicator turns red when any monitor is down, green when healthy - Press / to filter sites by name, Enter to lock filter, Esc to clear - Active filter shown in status bar --- internal/tui/tui.go | 107 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ddccd8b..813b456 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -82,6 +82,9 @@ type Model struct { alerts []models.AlertConfig users []models.User nodes []models.ProbeNode + + filterMode bool + filterText string } func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { @@ -247,6 +250,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen } + if m.filterMode { + switch msg.String() { + case "esc": + m.filterMode = false + m.filterText = "" + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + case "enter": + m.filterMode = false + case "backspace": + if len(m.filterText) > 0 { + m.filterText = m.filterText[:len(m.filterText)-1] + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + case "ctrl+c": + return m, tea.Quit + default: + if len(msg.String()) == 1 { + m.filterText += msg.String() + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + } + return m, nil + } + switch m.state { case stateDetail: switch msg.String() { @@ -260,6 +293,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m, tea.Quit + case "/": + if m.currentTab == 0 { + m.filterMode = true + return m, nil + } case "tab": m.switchTab(m.currentTab + 1) case "pgup", "pgdown": @@ -470,10 +508,10 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] - sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) + sort.Slice(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } - sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) + sort.Slice(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site for _, g := range groups { @@ -483,6 +521,16 @@ func (m *Model) refreshData() { } } ordered = append(ordered, ungrouped...) + if m.filterText != "" { + var filtered []models.Site + needle := strings.ToLower(m.filterText) + for _, s := range ordered { + if strings.Contains(strings.ToLower(s.Name), needle) { + filtered = append(filtered, s) + } + } + ordered = filtered + } m.sites = ordered if alerts, err := m.store.GetAllAlerts(); err == nil { m.alerts = alerts @@ -536,7 +584,19 @@ func (m Model) pulseIndicator() string { if brightness > 255 { brightness = 255 } - color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + hasDown := false + for _, s := range m.sites { + if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { + hasDown = true + break + } + } + var color string + if hasDown { + color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4) + } else { + color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + } return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) } @@ -674,16 +734,25 @@ func (m Model) viewDashboard() string { } statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) - var keys string - switch m.currentTab { - case 0: - keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" - case 4: - keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" - default: - keys = "[Tab]Switch [q]Quit" + var footer string + if m.filterMode { + cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│") + footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") + } else { + var keys string + switch m.currentTab { + case 0: + keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + case 4: + keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + default: + keys = "[Tab]Switch [q]Quit" + } + footer = "\n" + statusLine + " " + subtleStyle.Render(keys) + if m.filterText != "" && m.currentTab == 0 { + footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + } } - footer := "\n" + statusLine + " " + subtleStyle.Render(keys) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight) @@ -691,6 +760,20 @@ func (m Model) viewDashboard() string { return s.Render(header + "\n" + content + "\n" + footer) } +func siteOrder(s models.Site) int { + if s.Paused { + return 3 + } + switch s.Status { + case "DOWN", "SSL EXP": + return 0 + case "PENDING": + return 2 + default: + return 1 + } +} + func limitStr(text string, max int) string { if len(text) > max { return text[:max-3] + "..." -- 2.52.0 From 426c38ea94ab910bcaff28eac44eac8ae1387168 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:33:20 -0400 Subject: [PATCH 2/7] fix(tui): use stable sort to prevent site list shuffling each tick --- internal/tui/tui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 813b456..1bc72b3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -508,10 +508,10 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] - sort.Slice(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) + sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } - sort.Slice(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) + sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site for _, g := range groups { -- 2.52.0 From cc9dc24892947f8ec4b50a0c3b381c6c90bf4c07 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:36:49 -0400 Subject: [PATCH 3/7] fix(tui): sort children by ID before status to prevent map-order shuffling --- internal/tui/tui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1bc72b3..6f32161 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -508,9 +508,11 @@ func (m *Model) refreshData() { sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) for pid := range children { c := children[pid] + sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) children[pid] = c } + sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) var ordered []models.Site -- 2.52.0 From f01533080f94d9d3ab95befa02419a07c5e95f59 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:43:34 -0400 Subject: [PATCH 4/7] feat(tui): split available width evenly between NAME and HISTORY columns --- internal/tui/tab_sites.go | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index faab54a..c41a26b 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -195,19 +195,31 @@ func fmtStatus(status string, paused bool) string { } } -func (m Model) nameWidth() int { - w := m.termWidth - 105 - if w < 13 { - w = 13 +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 + if avail < 30 { + avail = 30 } - if w > 40 { - w = 40 + nameW = avail / 2 + sparkW = avail - nameW - 4 // -4 for spark column padding + if nameW < 13 { + nameW = 13 } - return w + if nameW > 40 { + nameW = 40 + } + if sparkW < 10 { + sparkW = 10 + } + if sparkW > 40 { + sparkW = 40 + } + return } func (m Model) viewSitesTab() string { - const sparkWidth = 20 if len(m.sites) == 0 { welcome := lipgloss.NewStyle(). @@ -222,6 +234,7 @@ func (m Model) viewSitesTab() string { return "\n" + welcome } + nameW, sparkWidth := m.dynamicWidths() colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} var groupRows map[int]bool @@ -242,7 +255,7 @@ func (m Model) viewSitesTab() string { } rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, m.nameWidth()-2)), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, nameW-2)), "group", fmtStatus(site.Status, site.Paused), subtleStyle.Render("—"), @@ -260,9 +273,9 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, m.nameWidth()-2) + name = prefix + " " + limitStr(name, nameW-2) } else { - name = limitStr(name, m.nameWidth()) + name = limitStr(name, nameW) } hist, _ := m.engine.GetHistory(site.ID) -- 2.52.0 From 1917540731d3eca2845c0ff9a445a08544e2de87 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:49:20 -0400 Subject: [PATCH 5/7] fix(tui): sparkline now spans full column width --- 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 c41a26b..ebc4253 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -203,7 +203,7 @@ func (m Model) dynamicWidths() (nameW, sparkW int) { avail = 30 } nameW = avail / 2 - sparkW = avail - nameW - 4 // -4 for spark column padding + sparkW = avail - nameW - 2 // -2 for spark column padding if nameW < 13 { nameW = 13 } @@ -235,7 +235,7 @@ func (m Model) viewSitesTab() string { } nameW, sparkWidth := m.dynamicWidths() - colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} + colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} var groupRows map[int]bool return m.renderTable( -- 2.52.0 From fc7b6f72e118910e104258360956eda6a6c5cc2d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:57:41 -0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(tui):=20sparkline=20right-aligned=20?= =?UTF-8?q?=E2=80=94=20current=20time=20at=20right=20edge,=20dots=20fill?= =?UTF-8?q?=20left?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/tab_sites.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ebc4253..ffd50dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -61,6 +61,9 @@ func latencySparkline(latencies []time.Duration, width int) string { } var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } spread := maxL - minL for _, l := range samples { idx := 0 @@ -80,10 +83,6 @@ func latencySparkline(latencies []time.Duration, width int) string { sb.WriteString(dangerStyle.Render(ch)) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } return sb.String() } @@ -98,6 +97,9 @@ func heartbeatSparkline(statuses []bool, width int) string { } var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } for _, up := range samples { if up { sb.WriteString(specialStyle.Render("▁")) @@ -105,10 +107,6 @@ func heartbeatSparkline(statuses []bool, width int) string { sb.WriteString(dangerStyle.Render("█")) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } return sb.String() } -- 2.52.0 From adf46a165424789b988c3a8eff9d416bc96b77fa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 14:01:25 -0400 Subject: [PATCH 7/7] fix(tui): increase history buffer to 60 so sparkline fills completely --- internal/monitor/history.go | 2 +- internal/tui/tab_sites.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/monitor/history.go b/internal/monitor/history.go index 1049e04..69a7f4d 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -2,7 +2,7 @@ package monitor import "time" -const maxHistoryLen = 30 +const maxHistoryLen = 60 type SiteHistory struct { Latencies []time.Duration diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ffd50dc..9417d9c 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -211,8 +211,8 @@ func (m Model) dynamicWidths() (nameW, sparkW int) { if sparkW < 10 { sparkW = 10 } - if sparkW > 40 { - sparkW = 40 + if sparkW > 60 { + sparkW = 60 } return } -- 2.52.0