From 22c60221215ccb437ad5d6f7b849b92773aa0ec3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 13:28:37 -0400 Subject: [PATCH] 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] + "..."