fix: six small fixes — rate limiter leak, DST SLA, probe sort, TUI cleanup
CI / test (pull_request) Successful in 1m55s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 56s

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.
This commit was merged in pull request #117.
This commit is contained in:
2026-06-12 09:18:52 -04:00
parent edfe6122b1
commit 9115ab720c
6 changed files with 34 additions and 34 deletions
+2 -5
View File
@@ -131,14 +131,11 @@ func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.St
reports := make([]DayReport, days) reports := make([]DayReport, days)
for i := 0; i < days; i++ { 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 { if i == 0 {
dayEnd = now 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) windowChanges := filterChangesForWindow(changes, dayStart, dayEnd)
+19 -7
View File
@@ -25,6 +25,7 @@ type RateLimiter struct {
rate float64 rate float64
burst float64 burst float64
trusted []*net.IPNet trusted []*net.IPNet
stop chan struct{}
} }
func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter { 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, rate: float64(requestsPerMinute) / 60.0,
burst: float64(requestsPerMinute), burst: float64(requestsPerMinute),
trusted: trusted, trusted: trusted,
stop: make(chan struct{}),
} }
go rl.cleanup() go rl.cleanup()
return rl return rl
} }
func (rl *RateLimiter) Stop() {
close(rl.stop)
}
func (rl *RateLimiter) Allow(ip string) bool { func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -84,16 +90,22 @@ func (rl *RateLimiter) evictOldest() {
} }
func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for { for {
time.Sleep(5 * time.Minute) select {
rl.mu.Lock() case <-ticker.C:
cutoff := time.Now().Add(-10 * time.Minute) rl.mu.Lock()
for ip, v := range rl.visitors { cutoff := time.Now().Add(-10 * time.Minute)
if v.lastSeen.Before(cutoff) { for ip, v := range rl.visitors {
delete(rl.visitors, ip) if v.lastSeen.Before(cutoff) {
delete(rl.visitors, ip)
}
} }
rl.mu.Unlock()
case <-rl.stop:
return
} }
rl.mu.Unlock()
} }
} }
+2 -8
View File
@@ -75,10 +75,7 @@ func fmtAlertType(t string) string {
} }
} }
func (m Model) fmtAlertConfig(alert struct { func (m Model) fmtAlertConfig(alert models.AlertConfig) string {
Type string
Settings map[string]string
}) string {
switch alert.Type { switch alert.Type {
case "email": case "email":
host := alert.Settings["host"] host := alert.Settings["host"]
@@ -201,10 +198,7 @@ func (m Model) viewAlertsTab() string {
m.fmtAlertHealth(h), m.fmtAlertHealth(h),
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
fmtAlertType(a.Type), fmtAlertType(a.Type),
limitStr(m.fmtAlertConfig(struct { limitStr(m.fmtAlertConfig(a), cfgW-2),
Type string
Settings map[string]string
}{a.Type, a.Settings}), cfgW-2),
m.fmtAlertLastSent(h), m.fmtAlertLastSent(h),
}) })
} }
+2 -8
View File
@@ -44,10 +44,7 @@ func TestAlertDetailPanel_MasksSecretsStableOrder(t *testing.T) {
func TestFmtAlertConfig_MasksSecrets(t *testing.T) { func TestFmtAlertConfig_MasksSecrets(t *testing.T) {
m := newTestModel(&tuiMockStore{}) m := newTestModel(&tuiMockStore{})
webhook := m.fmtAlertConfig(struct { webhook := m.fmtAlertConfig(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}})
Type string
Settings map[string]string
}{"discord", map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}})
if strings.Contains(webhook, "SeCrEtToKeN") || strings.Contains(webhook, "123456") { if strings.Contains(webhook, "SeCrEtToKeN") || strings.Contains(webhook, "123456") {
t.Errorf("webhook URL path (the credential) rendered in table: %q", webhook) 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) t.Errorf("webhook host missing from table config: %q", webhook)
} }
pd := m.fmtAlertConfig(struct { pd := m.fmtAlertConfig(models.AlertConfig{Type: "pagerduty", Settings: map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}})
Type string
Settings map[string]string
}{"pagerduty", map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}})
if strings.Contains(pd, "R0123456789ABCDEFGHIJ") { if strings.Contains(pd, "R0123456789ABCDEFGHIJ") {
t.Errorf("pagerduty routing key rendered raw in table: %q", pd) t.Errorf("pagerduty routing key rendered raw in table: %q", pd)
} }
+1 -5
View File
@@ -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) { 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, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "ctrl+c" { if keyMsg.String() == "ctrl+c" {
return m, tea.Quit 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 m, writeCmd("Save theme", func() error {
return st.SetPreference(context.Background(), "theme", name) return st.SetPreference(context.Background(), "theme", name)
}) })
case "d", "backspace": case "d":
return m.handleDeleteItem() return m.handleDeleteItem()
} }
return m, nil return m, nil
+8 -1
View File
@@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -163,8 +164,14 @@ func (m Model) viewDetailPanel() string {
probeResults := m.engine.GetProbeResults(site.ID) probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 { 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") 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") status := m.st.specialStyle.Render("UP")
if !result.IsUp { if !result.IsUp {
status = m.st.dangerStyle.Render("DN") status = m.st.dangerStyle.Render("DN")