From 9115ab720c70aab4319dc5ab10e2d66c0e6de9dd Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 12 Jun 2026 09:18:52 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20six=20small=20fixes=20=E2=80=94=20rate?= =?UTF-8?q?=20limiter=20leak,=20DST=20SLA,=20probe=20sort,=20TUI=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rate limiter cleanup goroutine now stoppable via Stop() channel instead of looping forever. Prevents goroutine leak in tests. 2. Dead WindowSizeMsg branch in handleFormMsg removed — top-level Update handles resize before forms see it. 3. Probe results sorted by node ID — map iteration no longer reorders rows every render. 4. fmtAlertConfig takes models.AlertConfig directly instead of an anonymous struct the caller builds inline. 5. Backspace no longer aliases delete — d is the documented key. Prevents accidental delete-confirm on habitual backspace. 6. SLA daily buckets use time.Date day arithmetic instead of Add(-i*24h) — lands on midnight correctly across DST transitions. --- internal/monitor/sla.go | 7 ++----- internal/server/ratelimit.go | 26 +++++++++++++++++++------- internal/tui/tab_alerts.go | 10 ++-------- internal/tui/tab_alerts_test.go | 10 ++-------- internal/tui/update.go | 6 +----- internal/tui/view_detail.go | 9 ++++++++- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/internal/monitor/sla.go b/internal/monitor/sla.go index cfa815a..7d0b1fb 100644 --- a/internal/monitor/sla.go +++ b/internal/monitor/sla.go @@ -131,14 +131,11 @@ func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.St reports := make([]DayReport, days) for i := 0; i < days; i++ { - dayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour) + dayStart := time.Date(now.Year(), now.Month(), now.Day()-i, 0, 0, 0, 0, now.Location()) + dayEnd := time.Date(now.Year(), now.Month(), now.Day()-i+1, 0, 0, 0, 0, now.Location()) if i == 0 { dayEnd = now } - dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour) - if i > 0 { - dayEnd = dayStart.Add(24 * time.Hour) - } windowChanges := filterChangesForWindow(changes, dayStart, dayEnd) diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go index ca3aac7..e963b26 100644 --- a/internal/server/ratelimit.go +++ b/internal/server/ratelimit.go @@ -25,6 +25,7 @@ type RateLimiter struct { rate float64 burst float64 trusted []*net.IPNet + stop chan struct{} } func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter { @@ -33,11 +34,16 @@ func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter { rate: float64(requestsPerMinute) / 60.0, burst: float64(requestsPerMinute), trusted: trusted, + stop: make(chan struct{}), } go rl.cleanup() return rl } +func (rl *RateLimiter) Stop() { + close(rl.stop) +} + func (rl *RateLimiter) Allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() @@ -84,16 +90,22 @@ func (rl *RateLimiter) evictOldest() { } func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() for { - time.Sleep(5 * time.Minute) - rl.mu.Lock() - cutoff := time.Now().Add(-10 * time.Minute) - for ip, v := range rl.visitors { - if v.lastSeen.Before(cutoff) { - delete(rl.visitors, ip) + select { + case <-ticker.C: + rl.mu.Lock() + cutoff := time.Now().Add(-10 * time.Minute) + for ip, v := range rl.visitors { + if v.lastSeen.Before(cutoff) { + delete(rl.visitors, ip) + } } + rl.mu.Unlock() + case <-rl.stop: + return } - rl.mu.Unlock() } } diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 2edc900..c936cad 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -75,10 +75,7 @@ func fmtAlertType(t string) string { } } -func (m Model) fmtAlertConfig(alert struct { - Type string - Settings map[string]string -}) string { +func (m Model) fmtAlertConfig(alert models.AlertConfig) string { switch alert.Type { case "email": host := alert.Settings["host"] @@ -201,10 +198,7 @@ func (m Model) viewAlertsTab() string { m.fmtAlertHealth(h), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), fmtAlertType(a.Type), - limitStr(m.fmtAlertConfig(struct { - Type string - Settings map[string]string - }{a.Type, a.Settings}), cfgW-2), + limitStr(m.fmtAlertConfig(a), cfgW-2), m.fmtAlertLastSent(h), }) } diff --git a/internal/tui/tab_alerts_test.go b/internal/tui/tab_alerts_test.go index 71e967d..66041aa 100644 --- a/internal/tui/tab_alerts_test.go +++ b/internal/tui/tab_alerts_test.go @@ -44,10 +44,7 @@ func TestAlertDetailPanel_MasksSecretsStableOrder(t *testing.T) { func TestFmtAlertConfig_MasksSecrets(t *testing.T) { m := newTestModel(&tuiMockStore{}) - webhook := m.fmtAlertConfig(struct { - Type string - Settings map[string]string - }{"discord", map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}}) + webhook := m.fmtAlertConfig(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}}) if strings.Contains(webhook, "SeCrEtToKeN") || strings.Contains(webhook, "123456") { t.Errorf("webhook URL path (the credential) rendered in table: %q", webhook) } @@ -55,10 +52,7 @@ func TestFmtAlertConfig_MasksSecrets(t *testing.T) { t.Errorf("webhook host missing from table config: %q", webhook) } - pd := m.fmtAlertConfig(struct { - Type string - Settings map[string]string - }{"pagerduty", map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}}) + pd := m.fmtAlertConfig(models.AlertConfig{Type: "pagerduty", Settings: map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}}) if strings.Contains(pd, "R0123456789ABCDEFGHIJ") { t.Errorf("pagerduty routing key rendered raw in table: %q", pd) } diff --git a/internal/tui/update.go b/internal/tui/update.go index 19b866b..633cc2b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -110,10 +110,6 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { - if wsm, ok := msg.(tea.WindowSizeMsg); ok { - m.termWidth = wsm.Width - m.termHeight = wsm.Height - } if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "ctrl+c" { return m, tea.Quit @@ -609,7 +605,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, writeCmd("Save theme", func() error { return st.SetPreference(context.Background(), "theme", name) }) - case "d", "backspace": + case "d": return m.handleDeleteItem() } return m, nil diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index d591575..b9810bb 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "sort" "strconv" "strings" "time" @@ -163,8 +164,14 @@ func (m Model) viewDetailPanel() string { probeResults := m.engine.GetProbeResults(site.ID) if len(probeResults) > 0 { + nodeIDs := make([]string, 0, len(probeResults)) + for id := range probeResults { + nodeIDs = append(nodeIDs, id) + } + sort.Strings(nodeIDs) b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n") - for nodeID, result := range probeResults { + for _, nodeID := range nodeIDs { + result := probeResults[nodeID] status := m.st.specialStyle.Render("UP") if !result.IsUp { status = m.st.dangerStyle.Render("DN")