2 Commits

Author SHA1 Message Date
Niklas Ye 4740687b96 feat!: stats as a section instead of an overlay
Release / test (push) Failing after 6s
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / release (push) Has been skipped
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.
2026-08-06 12:46:38 +02:00
Niklas Ye 1cb3fc3d14 ci: check every push and pull request
The release workflow gates a tag, which is the last possible moment: a
commit that breaks the suite stays green on main until somebody decides
to publish.

Runs go vet and go test on pushes to main and on pull requests. push is
scoped to main so a branch pushed as part of a pull request is not
checked twice, and runs for the same ref cancel each other.
2026-07-31 07:29:00 +02:00
8 changed files with 166 additions and 87 deletions
+32
View File
@@ -0,0 +1,32 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green
# until somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main so that a branch pushed as part of a pull request is
# not checked twice.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
+1
View File
@@ -73,6 +73,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
## Sections ## Sections
`Incidents` (the queue, and the default) · `Alerts` (raw read-only feed) · `Incidents` (the queue, and the default) · `Alerts` (raw read-only feed) ·
`Stats` (MTTA/MTTR and alert frequency charts) ·
`Archived` (archived incidents) · `Schedule` · `Users` `Archived` (archived incidents) · `Schedule` · `Users`
## Development stages ## Development stages
+8 -1
View File
@@ -78,9 +78,10 @@ Global:
| `esc` | Go back | | `esc` | Go back |
| `r` | Refresh | | `r` | Refresh |
| `f` | Cycle filter | | `f` | Cycle filter |
| `S` | Statistics |
| `q` | Quit | | `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
Incidents section: Incidents section:
| Key | Action | | Key | Action |
@@ -108,6 +109,12 @@ Alerts section (read-only):
| `f` | Cycle: firing → resolved → all → archived | | `f` | Cycle: firing → resolved → all → archived |
| `i` | In detail: jump to the alert's incident | | `i` | In detail: jump to the alert's incident |
Stats section:
| Key | Action |
|-----|--------|
| `j` / `k`, `pgup` / `pgdn` | Scroll |
Schedule section: Schedule section:
| Key | Action | | Key | Action |
+21 -8
View File
@@ -21,11 +21,12 @@ const (
// Incidents lead: they are the work. Alerts is the raw feed underneath. // Incidents lead: they are the work. Alerts is the raw feed underneath.
sectionIncidents section = iota sectionIncidents section = iota
sectionAlerts sectionAlerts
sectionStats
sectionArchived sectionArchived
sectionSchedule sectionSchedule
sectionUsers sectionUsers
sectionCount = 5 sectionCount = 6
) )
type mode int type mode int
@@ -37,7 +38,6 @@ const (
modeNote modeNote
modeSnooze modeSnooze
modeConfirm modeConfirm
modeStats
modeUserPicker modeUserPicker
modeUserCreate modeUserCreate
modeAPIKeyMenu modeAPIKeyMenu
@@ -195,14 +195,14 @@ type Model struct {
pendingDeleteEntry *api.ScheduleEntry pendingDeleteEntry *api.ScheduleEntry
// Stats // Stats
topAlerts []api.TopAlert topAlerts []api.TopAlert
hourStats []api.HourStat hourStats []api.HourStat
dayStats []api.DayStat 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 statsLoading bool
statsViewport viewport.Model statsViewport viewport.Model
// statsReturnMode is where esc goes back to, since stats opens from both
// the dashboard and an incident.
statsReturnMode mode
// Schedule // Schedule
scheduleWindow time.Time scheduleWindow time.Time
@@ -253,6 +253,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
manageT := table.New(table.WithFocused(true)) manageT := table.New(table.WithFocused(true))
manageT.SetStyles(ts) 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 := textinput.New()
noteIn.Placeholder = "type your note…" noteIn.Placeholder = "type your note…"
noteIn.CharLimit = 1000 noteIn.CharLimit = 1000
@@ -298,6 +302,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
incidentTable: incidentT, incidentTable: incidentT,
alertTable: alertT, alertTable: alertT,
archivedTable: archivedT, archivedTable: archivedT,
statsViewport: statsVP,
noteInput: noteIn, noteInput: noteIn,
snoozeInput: snoozeIn, snoozeInput: snoozeIn,
scheduleWindow: window, scheduleWindow: window,
@@ -397,6 +402,14 @@ func (m *Model) refreshStatsContent() {
buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) 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 { func (m Model) detailViewportHeight() int {
h := m.height - 5 h := m.height - 5
if m.mode == modeNote || m.mode == modeSnooze { if m.mode == modeNote || m.mode == modeSnooze {
+18 -41
View File
@@ -24,7 +24,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detailViewport.Width = m.width m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width m.statsViewport.Width = m.width
m.statsViewport.Height = m.height - 5 m.statsViewport.Height = m.statsViewportHeight()
m.refreshDetailContent() m.refreshDetailContent()
m.refreshStatsContent() m.refreshStatsContent()
return m, nil return m, nil
@@ -118,13 +118,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.hourStats = msg.byHour m.hourStats = msg.byHour
m.dayStats = msg.byDay m.dayStats = msg.byDay
m.statsLoading = false m.statsLoading = false
m.statsLoaded = true
m.refreshStatsContent() m.refreshStatsContent()
return m, nil return m, nil
case detailStatsErrMsg: case detailStatsErrMsg:
m.statsLoading = false 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.statusMsg = "stats error: " + msg.err.Error()
m.mode = modeDashboard
return m, clearStatusCmd() return m, clearStatusCmd()
// ── Schedule messages ───────────────────────────────────────────────── // ── Schedule messages ─────────────────────────────────────────────────
@@ -205,6 +208,9 @@ func (m Model) refreshActiveSection() tea.Cmd {
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client)) return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
case sectionAlerts: case sectionAlerts:
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client)) 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: case sectionArchived:
return fetchArchivedIncidentsCmd(m.client) return fetchArchivedIncidentsCmd(m.client)
case sectionSchedule: case sectionSchedule:
@@ -240,12 +246,6 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd) 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: case modeUserPicker:
var tableCmd tea.Cmd var tableCmd tea.Cmd
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg) 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) m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd) 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: case sectionArchived:
var tableCmd tea.Cmd var tableCmd tea.Cmd
m.archivedTable, tableCmd = m.archivedTable.Update(msg) 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) return m.handleSnoozeKey(msg)
case modeConfirm: case modeConfirm:
return m.handleConfirmKey(msg) return m.handleConfirmKey(msg)
case modeStats:
return m.handleStatsKey(msg)
case modeUserPicker: case modeUserPicker:
return m.handleUserPickerKey(msg) return m.handleUserPickerKey(msg)
case modeUserCreate: case modeUserCreate:
@@ -482,12 +485,6 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
case "S":
if !m.connected {
return m, nil
}
return m.openStats()
case "n": case "n":
if m.activeSection != sectionUsers || !m.connected { if m.activeSection != sectionUsers || !m.connected {
return m, nil return m, nil
@@ -524,6 +521,11 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
m.loading = true m.loading = true
return fetchAlertsCmd(m.client, m.alertFilter) 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: case sectionArchived:
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
m.archivedLoading = true m.archivedLoading = true
@@ -677,9 +679,6 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeConfirm m.mode = modeConfirm
return m, nil return m, nil
case "S":
return m.openStats()
case "[": case "[":
return m.moveNoteCursor(-1), nil return m.moveNoteCursor(-1), nil
@@ -733,9 +732,6 @@ func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, clearStatusCmd() return m, clearStatusCmd()
} }
return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID}) return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID})
case "S":
return m.openStats()
} }
return m, nil return m, nil
@@ -835,25 +831,6 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil 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 ─────────────────────────────────────────────────────────── // ── User picker ───────────────────────────────────────────────────────────
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
+49 -24
View File
@@ -315,7 +315,8 @@ func TestTab_CyclesEverySection(t *testing.T) {
t.Fatal("incidents is the section the client opens on") 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 { for i, expected := range want {
m, _ = press(t, m, "tab") m, _ = press(t, m, "tab")
if m.activeSection != expected { 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 // Stats is a section like any other: no key of its own, no mode of its own, and
// wherever it was opened from. // it loads once on first visit rather than on every tab-in — the three empty
func TestStats_ReturnsWhereItWasOpenedFrom(t *testing.T) { // slices a quiet server returns are a real answer, not a missing one.
t.Run("from the queue", func(t *testing.T) { func TestStats_IsAnOrdinarySection(t *testing.T) {
m, _ := press(t, sized(), "S") m := sized()
if m.mode != modeStats { m.activeSection = sectionAlerts
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)
}
})
t.Run("from an incident", func(t *testing.T) { m, cmd := press(t, m, "tab")
m, _ := press(t, onIncident(openIncidentFixture(), nil), "S") if m.activeSection != sectionStats {
if m.mode != modeStats { t.Fatalf("expected the stats section, got %v", m.activeSection)
t.Fatalf("expected stats, got mode %v", m.mode) }
} if m.mode != modeDashboard {
m, _ = press(t, m, "esc") t.Errorf("stats is a section, not a mode: got mode %v", m.mode)
if m.mode != modeIncidentDetail { }
t.Errorf("expected the incident, 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 // Alerts carry no workflow state, so the detail view offers nothing but a way
+14 -12
View File
@@ -10,7 +10,8 @@ import (
"github.com/yeniklas/terdut-tui/internal/api" "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 { func (m Model) View() string {
if m.width == 0 { if m.width == 0 {
@@ -68,8 +69,6 @@ func (m Model) renderBody() string {
default: default:
return m.renderSchedule() return m.renderSchedule()
} }
case modeStats:
return m.renderStats()
case modeUserPicker: case modeUserPicker:
return m.renderUserPicker() return m.renderUserPicker()
case modeUserCreate: case modeUserCreate:
@@ -99,12 +98,12 @@ func (m Model) renderFooter() string {
switch m.mode { switch m.mode {
case modeIncidentDetail: case modeIncidentDetail:
if !m.selectedIncident.IsOpen() { 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: case modeAlertDetail:
return withStatus(" i·open incident S·stats esc·back") return withStatus(" i·open incident esc·back")
case modeNote: case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel") return "\n" + styleFooter.Render(" enter·submit esc·cancel")
@@ -115,9 +114,6 @@ func (m Model) renderFooter() string {
case modeConfirm: case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt()) return "\n" + styleError.Render(" "+m.confirmPrompt())
case modeStats:
return withStatus(" esc·back")
case modeUserPicker: case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
return withStatus(" j/k·navigate enter·assign incident esc·cancel") return withStatus(" j/k·navigate enter·assign incident esc·cancel")
@@ -146,9 +142,11 @@ func (m Model) renderFooter() string {
default: default:
switch m.activeSection { switch m.activeSection {
case sectionIncidents: 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: 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: case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit") return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
case sectionSchedule: case sectionSchedule:
@@ -187,6 +185,8 @@ func (m Model) renderDashboard() string {
return m.renderIncidents() return m.renderIncidents()
case sectionAlerts: case sectionAlerts:
return m.renderAlerts() return m.renderAlerts()
case sectionStats:
return m.renderStats()
case sectionArchived: case sectionArchived:
return m.renderArchived() return m.renderArchived()
case sectionSchedule: case sectionSchedule:
@@ -359,7 +359,9 @@ func (m Model) renderPrompt(prompt string) string {
// ── Stats ────────────────────────────────────────────────────────────────── // ── Stats ──────────────────────────────────────────────────────────────────
func (m Model) renderStats() string { 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 "\n" + styleMuted.Render(" Loading statistics…")
} }
return m.statsViewport.View() return m.statsViewport.View()
+23 -1
View File
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api" "github.com/yeniklas/terdut-tui/internal/api"
) )
@@ -239,7 +240,7 @@ func TestView_TabsAndDashboardRender(t *testing.T) {
m.rebuildIncidentTable() m.rebuildIncidentTable()
mustContain(t, m.View(), mustContain(t, m.View(),
"Incidents", "Alerts", "Archived", "Schedule", "Users", "Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users",
"Triggered: 1", "filter: open", "Triggered: 1", "filter: open",
"DiskFull", "critical", "admin", "DiskFull", "critical", "admin",
"enter·detail") "enter·detail")
@@ -254,6 +255,27 @@ func TestView_EmptyStates(t *testing.T) {
mustContain(t, m.View(), "No archived incidents.") 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) { func TestView_ConnectionError(t *testing.T) {
m := sized() m := sized()
m.connected = false m.connected = false