From 4740687b9665317da517b98e5f029c4142204a84 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 6 Aug 2026 12:46:38 +0200 Subject: [PATCH] feat!: stats as a section instead of an overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stats was the one full-screen view reached by a key of its own rather than by tab, and the interface was less coherent for it. It is now a section sitting third, after Alerts, and behaves like every other one: tab in, tab out, r to refresh. Three things fall out of the move. It auto-refreshes for the first time — the tick handler skips every non-dashboard mode, which is why the overlay never updated while it was open. Its error path no longer forces the queue back into view on a failed fetch, an assumption that only made sense while stats floated above the dashboard. And first-visit loading keys off a statsLoaded flag rather than slice emptiness, because the three empty slices a quiet server returns are a real answer, not a missing one; the loading placeholder is likewise suppressed once something has been drawn, so a background refresh cannot blank the page out from under whoever is reading it. The S key is gone, and with it the ability to peek at statistics from an open incident and land back on it. That round-trip was the only thing statsReturnMode bought, and it was the whole reason stats needed a mode. --- CLAUDE.md | 1 + README.md | 9 ++++- internal/tui/model.go | 29 +++++++++++---- internal/tui/update.go | 59 +++++++++--------------------- internal/tui/update_test.go | 73 +++++++++++++++++++++++++------------ internal/tui/view.go | 26 +++++++------ internal/tui/view_test.go | 24 +++++++++++- 7 files changed, 134 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c154a5..b72a4ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . ## Sections `Incidents` (the queue, and the default) · `Alerts` (raw read-only feed) · +`Stats` (MTTA/MTTR and alert frequency charts) · `Archived` (archived incidents) · `Schedule` · `Users` ## Development stages diff --git a/README.md b/README.md index 48557b9..d79d06e 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,10 @@ Global: | `esc` | Go back | | `r` | Refresh | | `f` | Cycle filter | -| `S` | Statistics | | `q` | Quit | +The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users. + Incidents section: | Key | Action | @@ -108,6 +109,12 @@ Alerts section (read-only): | `f` | Cycle: firing → resolved → all → archived | | `i` | In detail: jump to the alert's incident | +Stats section: + +| Key | Action | +|-----|--------| +| `j` / `k`, `pgup` / `pgdn` | Scroll | + Schedule section: | Key | Action | diff --git a/internal/tui/model.go b/internal/tui/model.go index 1d35cc1..790763b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,11 +21,12 @@ const ( // Incidents lead: they are the work. Alerts is the raw feed underneath. sectionIncidents section = iota sectionAlerts + sectionStats sectionArchived sectionSchedule sectionUsers - sectionCount = 5 + sectionCount = 6 ) type mode int @@ -37,7 +38,6 @@ const ( modeNote modeSnooze modeConfirm - modeStats modeUserPicker modeUserCreate modeAPIKeyMenu @@ -195,14 +195,14 @@ type Model struct { pendingDeleteEntry *api.ScheduleEntry // Stats - topAlerts []api.TopAlert - hourStats []api.HourStat - dayStats []api.DayStat + topAlerts []api.TopAlert + hourStats []api.HourStat + dayStats []api.DayStat + // statsLoaded tracks the first fetch separately from emptiness: a server with + // no alerts yet legitimately returns three empty slices. + statsLoaded bool statsLoading bool statsViewport viewport.Model - // statsReturnMode is where esc goes back to, since stats opens from both - // the dashboard and an incident. - statsReturnMode mode // Schedule scheduleWindow time.Time @@ -253,6 +253,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio manageT := table.New(table.WithFocused(true)) manageT.SetStyles(ts) + // Sized by the first tea.WindowSizeMsg; built here so it carries the default + // scroll keymap, which the zero value lacks. + statsVP := viewport.New(0, 0) + noteIn := textinput.New() noteIn.Placeholder = "type your note…" noteIn.CharLimit = 1000 @@ -298,6 +302,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio incidentTable: incidentT, alertTable: alertT, archivedTable: archivedT, + statsViewport: statsVP, noteInput: noteIn, snoozeInput: snoozeIn, scheduleWindow: window, @@ -397,6 +402,14 @@ func (m *Model) refreshStatsContent() { buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) } +func (m Model) statsViewportHeight() int { + h := m.height - 5 + if h < 1 { + h = 1 + } + return h +} + func (m Model) detailViewportHeight() int { h := m.height - 5 if m.mode == modeNote || m.mode == modeSnooze { diff --git a/internal/tui/update.go b/internal/tui/update.go index e316ccf..898c923 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -24,7 +24,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailViewport.Width = m.width m.detailViewport.Height = m.detailViewportHeight() m.statsViewport.Width = m.width - m.statsViewport.Height = m.height - 5 + m.statsViewport.Height = m.statsViewportHeight() m.refreshDetailContent() m.refreshStatsContent() return m, nil @@ -118,13 +118,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.hourStats = msg.byHour m.dayStats = msg.byDay m.statsLoading = false + m.statsLoaded = true m.refreshStatsContent() return m, nil case detailStatsErrMsg: m.statsLoading = false + // Mark it loaded even on failure, so tabbing back in does not re-fire the + // request every time. The tick and r still retry. + m.statsLoaded = true m.statusMsg = "stats error: " + msg.err.Error() - m.mode = modeDashboard return m, clearStatusCmd() // ── Schedule messages ───────────────────────────────────────────────── @@ -205,6 +208,9 @@ func (m Model) refreshActiveSection() tea.Cmd { return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client)) case sectionAlerts: return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client)) + case sectionStats: + // Both: fetchStatsCmd feeds the Incident Response block, the other the charts. + return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client)) case sectionArchived: return fetchArchivedIncidentsCmd(m.client) case sectionSchedule: @@ -240,12 +246,6 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) - case modeStats: - var vpCmd tea.Cmd - m.statsViewport, vpCmd = m.statsViewport.Update(msg) - m2, ourCmd := m.handleKey(msg) - return m2, tea.Batch(vpCmd, ourCmd) - case modeUserPicker: var tableCmd tea.Cmd m.userPickerTable, tableCmd = m.userPickerTable.Update(msg) @@ -286,6 +286,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.alertTable, tableCmd = m.alertTable.Update(msg) m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(tableCmd, ourCmd) + case sectionStats: + var vpCmd tea.Cmd + m.statsViewport, vpCmd = m.statsViewport.Update(msg) + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(vpCmd, ourCmd) case sectionArchived: var tableCmd tea.Cmd m.archivedTable, tableCmd = m.archivedTable.Update(msg) @@ -319,8 +324,6 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleSnoozeKey(msg) case modeConfirm: return m.handleConfirmKey(msg) - case modeStats: - return m.handleStatsKey(msg) case modeUserPicker: return m.handleUserPickerKey(msg) case modeUserCreate: @@ -482,12 +485,6 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { } return m, nil - case "S": - if !m.connected { - return m, nil - } - return m.openStats() - case "n": if m.activeSection != sectionUsers || !m.connected { return m, nil @@ -524,6 +521,11 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd { m.loading = true return fetchAlertsCmd(m.client, m.alertFilter) } + case sectionStats: + if !m.statsLoaded { + m.statsLoading = true + return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client)) + } case sectionArchived: if len(m.archivedIncidents) == 0 { m.archivedLoading = true @@ -677,9 +679,6 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.mode = modeConfirm return m, nil - case "S": - return m.openStats() - case "[": return m.moveNoteCursor(-1), nil @@ -733,9 +732,6 @@ func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, clearStatusCmd() } return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID}) - - case "S": - return m.openStats() } return m, nil @@ -835,25 +831,6 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } -// ── Stats ───────────────────────────────────────────────────────────────── - -func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) { - if msg.String() == "esc" { - m.mode = m.statsReturnMode - return m, nil - } - return m, nil -} - -// openStats enters the statistics view, remembering where to go back to. -func (m Model) openStats() (Model, tea.Cmd) { - m.statsReturnMode = m.mode - m.mode = modeStats - m.statsLoading = true - m.statsViewport = viewport.New(m.width, m.height-5) - return m, fetchDetailStatsCmd(m.client) -} - // ── User picker ─────────────────────────────────────────────────────────── func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 646a64c..75313b0 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -315,7 +315,8 @@ func TestTab_CyclesEverySection(t *testing.T) { t.Fatal("incidents is the section the client opens on") } - want := []section{sectionAlerts, sectionArchived, sectionSchedule, sectionUsers, sectionIncidents} + want := []section{sectionAlerts, sectionStats, sectionArchived, sectionSchedule, + sectionUsers, sectionIncidents} for i, expected := range want { m, _ = press(t, m, "tab") if m.activeSection != expected { @@ -341,30 +342,54 @@ func TestFilter_CyclesPerSection(t *testing.T) { } } -// Stats opens from both the queue and an incident, and esc has to go back to -// wherever it was opened from. -func TestStats_ReturnsWhereItWasOpenedFrom(t *testing.T) { - t.Run("from the queue", func(t *testing.T) { - m, _ := press(t, sized(), "S") - if m.mode != modeStats { - t.Fatalf("expected stats, got mode %v", m.mode) - } - m, _ = press(t, m, "esc") - if m.mode != modeDashboard { - t.Errorf("expected the dashboard, got mode %v", m.mode) - } - }) +// Stats is a section like any other: no key of its own, no mode of its own, and +// it loads once on first visit rather than on every tab-in — the three empty +// slices a quiet server returns are a real answer, not a missing one. +func TestStats_IsAnOrdinarySection(t *testing.T) { + m := sized() + m.activeSection = sectionAlerts - t.Run("from an incident", func(t *testing.T) { - m, _ := press(t, onIncident(openIncidentFixture(), nil), "S") - if m.mode != modeStats { - t.Fatalf("expected stats, got mode %v", m.mode) - } - m, _ = press(t, m, "esc") - if m.mode != modeIncidentDetail { - t.Errorf("expected the incident, got mode %v", m.mode) - } - }) + m, cmd := press(t, m, "tab") + if m.activeSection != sectionStats { + t.Fatalf("expected the stats section, got %v", m.activeSection) + } + if m.mode != modeDashboard { + t.Errorf("stats is a section, not a mode: got mode %v", m.mode) + } + if cmd == nil { + t.Error("the first visit should fetch") + } + + m.statsLoaded = true + m.statsLoading = false + if cmd := m.loadSectionIfEmpty(); cmd != nil { + t.Error("a second visit should reuse what was already fetched") + } +} + +// S used to open the stats overlay from anywhere. It is gone, and must not +// disturb the view it is pressed in. +func TestStats_KeyIsGone(t *testing.T) { + m, _ := press(t, sized(), "S") + if m.activeSection != sectionIncidents || m.mode != modeDashboard { + t.Errorf("S should do nothing on the queue, got section %v mode %v", + m.activeSection, m.mode) + } + + m, _ = press(t, onIncident(openIncidentFixture(), nil), "S") + if m.mode != modeIncidentDetail { + t.Errorf("S should leave the incident open, got mode %v", m.mode) + } +} + +// The overlay never auto-refreshed, because the tick skipped every non-dashboard +// mode. As a section it rides the tick like the rest. +func TestStats_RefreshesOnTick(t *testing.T) { + m := sized() + m.activeSection = sectionStats + if m.refreshActiveSection() == nil { + t.Error("the stats section should refresh on the tick") + } } // Alerts carry no workflow state, so the detail view offers nothing but a way diff --git a/internal/tui/view.go b/internal/tui/view.go index 18bed57..aa3de9c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -10,7 +10,8 @@ import ( "github.com/yeniklas/terdut-tui/internal/api" ) -var sectionNames = []string{"Incidents", "Alerts", "Archived", "Schedule", "Users"} +// Order must match the section constants — renderTabs indexes this by ordinal. +var sectionNames = []string{"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users"} func (m Model) View() string { if m.width == 0 { @@ -68,8 +69,6 @@ func (m Model) renderBody() string { default: return m.renderSchedule() } - case modeStats: - return m.renderStats() case modeUserPicker: return m.renderUserPicker() case modeUserCreate: @@ -99,12 +98,12 @@ func (m Model) renderFooter() string { switch m.mode { case modeIncidentDetail: if !m.selectedIncident.IsOpen() { - return withStatus(" x·archive c·note [/]·select d·del S·stats esc·back") + return withStatus(" x·archive c·note [/]·select d·del esc·back") } - return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del S·stats esc·back") + return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back") case modeAlertDetail: - return withStatus(" i·open incident S·stats esc·back") + return withStatus(" i·open incident esc·back") case modeNote: return "\n" + styleFooter.Render(" enter·submit esc·cancel") @@ -115,9 +114,6 @@ func (m Model) renderFooter() string { case modeConfirm: return "\n" + styleError.Render(" "+m.confirmPrompt()) - case modeStats: - return withStatus(" esc·back") - case modeUserPicker: if m.pickerTarget == pickerIncidentAssignee { return withStatus(" j/k·navigate enter·assign incident esc·cancel") @@ -146,9 +142,11 @@ func (m Model) renderFooter() string { default: switch m.activeSection { case sectionIncidents: - return withStatus(" enter·detail x·archive f·filter S·stats r·refresh tab·section q·quit") + return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit") case sectionAlerts: - return withStatus(" enter·detail f·filter S·stats r·refresh tab·section q·quit") + return withStatus(" enter·detail f·filter r·refresh tab·section q·quit") + case sectionStats: + return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit") case sectionArchived: return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit") case sectionSchedule: @@ -187,6 +185,8 @@ func (m Model) renderDashboard() string { return m.renderIncidents() case sectionAlerts: return m.renderAlerts() + case sectionStats: + return m.renderStats() case sectionArchived: return m.renderArchived() case sectionSchedule: @@ -359,7 +359,9 @@ func (m Model) renderPrompt(prompt string) string { // ── Stats ────────────────────────────────────────────────────────────────── func (m Model) renderStats() string { - if m.statsLoading { + // Only announce loading before the first result: a background refresh must not + // blank the page out from under whoever is reading it. + if m.statsLoading && !m.statsLoaded { return "\n" + styleMuted.Render(" Loading statistics…") } return m.statsViewport.View() diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index e48fd0b..28ca092 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/yeniklas/terdut-tui/internal/api" ) @@ -239,7 +240,7 @@ func TestView_TabsAndDashboardRender(t *testing.T) { m.rebuildIncidentTable() mustContain(t, m.View(), - "Incidents", "Alerts", "Archived", "Schedule", "Users", + "Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users", "Triggered: 1", "filter: open", "DiskFull", "critical", "admin", "enter·detail") @@ -254,6 +255,27 @@ func TestView_EmptyStates(t *testing.T) { mustContain(t, m.View(), "No archived incidents.") } +// The stats page renders inside the normal section chrome now, so it has to +// survive the real path: a window size message sizes the viewport and fills it. +func TestView_StatsSectionRendersInPlace(t *testing.T) { + m := NewModel(nil, "http://test", time.Minute) + m.connected = true + m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} + m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} + m.statsLoaded = true + + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = next.(Model) + m.activeSection = sectionStats + + out := m.View() + mustContain(t, out, "Stats", "Incident Response", "Top Alerts", "DiskFull", + "tab·section") + if strings.Contains(plain(out), "Loading statistics") { + t.Error("loaded stats should not show the loading placeholder") + } +} + func TestView_ConnectionError(t *testing.T) { m := sized() m.connected = false