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 faab54a..9417d9c 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() } @@ -195,19 +193,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 - 2 // -2 for spark column padding + if nameW < 13 { + nameW = 13 } - return w + if nameW > 40 { + nameW = 40 + } + if sparkW < 10 { + sparkW = 10 + } + if sparkW > 60 { + sparkW = 60 + } + return } func (m Model) viewSitesTab() string { - const sparkWidth = 20 if len(m.sites) == 0 { welcome := lipgloss.NewStyle(). @@ -222,7 +232,8 @@ func (m Model) viewSitesTab() string { return "\n" + welcome } - colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} + nameW, sparkWidth := m.dynamicWidths() + colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} var groupRows map[int]bool return m.renderTable( @@ -242,7 +253,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 +271,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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ddccd8b..6f32161 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": @@ -471,9 +509,11 @@ func (m *Model) refreshData() { 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 for _, g := range groups { @@ -483,6 +523,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 +586,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 +736,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 +762,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] + "..."