5 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
Niklas Ye 814ef2c5e8 fix: stop styled lines from indenting the text that follows
Release / test (push) Failing after 7s
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
An incident with nobody assigned and nobody holding it rendered its
detail view like this:

    Assigned:   nobody
                        Acked:      not acknowledged

lipgloss pads every line of a styled block out to the width of its widest
line. A trailing newline inside Render therefore produces a second line
made entirely of padding, and the next write to the builder starts after
that padding instead of at the left margin. Twelve call sites put the
newline inside.

Adding a line() helper that keeps the newline outside, and using it
throughout the content builders.

Shipped in v0.4.0 and only visible on the unassigned or unacknowledged
path, which is why it survived the pre-release check: that run had
somebody on call, so the incident was assigned and acknowledged and both
lines took the styled-with-value branch instead.

view_test.go covers the content builders, including the two states that
were broken and an expired snooze not being reported as a snooze.
2026-07-31 07:24:09 +02:00
Niklas Ye 8482315651 test: cover the API client and the update loop
The repo had no tests at all, which the v0.4.0 rewrite made
uncomfortable: this client speaks terdut-server's REST API directly, and
a wrong path or method is invisible until somebody runs the binary
against a live server. That is exactly how it broke when the server split
alerts from incidents.

The Elm architecture makes most of this cheap to check without a
terminal. Update is (Model, Msg) -> (Model, Cmd), so keypresses can be
synthesised and the resulting model inspected; a nil command is a
readable assertion that the model decided to do nothing.

Three suites:

  - client_test.go drives every incident endpoint against an httptest
    stub that records method, path, query and body. Also covers the
    filter query params, that a server error message survives into the
    error the UI shows, that a 404 from the on-call endpoint is not an
    error, and that omitted optional fields decode to zero rather than
    failing.
  - model_test.go covers the pure helpers: filter cycling, the snoozed
    pseudo-status, duration formatting, row builders, and the column
    width arithmetic that overflowed the terminal once already.
  - update_test.go covers the rules worth protecting rather than
    coverage for its own sake. Resolve prompts first and cancelling does
    not act, since resolution is terminal server-side. A resolved
    incident rejects all six workflow keys. Archiving refuses while an
    incident is open. The note cursor walks notes only and wraps. Stats
    returns to whichever view opened it. Modal states do not auto-refresh
    underneath the user.

129 tests, running in about 40ms.
2026-07-31 07:23:43 +02:00
Niklas Ye 9582543c1d ci: gate the release on vet and tests
The workflow only built and published, so a tag went straight to
binaries on the releases page with nothing having run against the code
first.

A test job now runs go vet and go test, and build depends on it; release
depends on build, so a tag that fails publishes nothing.

Only runs at release time — a push or pull request is still unchecked.
2026-07-31 07:23:28 +02:00
11 changed files with 1486 additions and 81 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 ./...
+19
View File
@@ -6,7 +6,26 @@ on:
- 'v*' - 'v*'
jobs: jobs:
# Gates the build, so a tag that fails here publishes no binaries. The suite
# covers the API client against a stub server, the Update state machine, and
# View rendering — all three are pure enough to test without a terminal.
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 ./...
build: build:
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
+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 |
+304
View File
@@ -0,0 +1,304 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// call records what the client actually put on the wire. The paths and methods
// are the contract with terdut-server, and getting one wrong is exactly how this
// client broke when the server split alerts from incidents.
type call struct {
method string
path string
query string
body string
auth string
}
// stub serves one canned response and records the request that fetched it.
func stub(t *testing.T, status int, response string) (*Client, *call) {
t.Helper()
got := &call{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
got.body, got.auth = string(body), r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return NewClient(srv.URL, "test-key"), got
}
func TestClient_SendsBearerToken(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents("", false, false, 0); err != nil {
t.Fatalf("list: %v", err)
}
if got.auth != "Bearer test-key" {
t.Errorf("expected bearer token, got %q", got.auth)
}
}
// Every incident action, with the method and path terdut-server exposes.
func TestClient_IncidentEndpoints(t *testing.T) {
tests := []struct {
name string
invoke func(*Client) error
method string
path string
// resp defaults to a JSON object; endpoints returning a list need an array.
resp string
}{
{"get", func(c *Client) error { _, err := c.GetIncident(7); return err },
http.MethodGet, "/api/incidents/7", ""},
{"timeline", func(c *Client) error { _, err := c.GetIncidentTimeline(7); return err },
http.MethodGet, "/api/incidents/7/timeline", `[]`},
{"acknowledge", func(c *Client) error { _, err := c.AcknowledgeIncident(7); return err },
http.MethodPost, "/api/incidents/7/acknowledge", ""},
{"unacknowledge", func(c *Client) error { return c.UnacknowledgeIncident(7) },
http.MethodDelete, "/api/incidents/7/acknowledge", ""},
{"resolve", func(c *Client) error { _, err := c.ResolveIncident(7); return err },
http.MethodPost, "/api/incidents/7/resolve", ""},
{"assign", func(c *Client) error { _, err := c.AssignIncident(7, 3); return err },
http.MethodPost, "/api/incidents/7/assign", ""},
{"snooze", func(c *Client) error { _, err := c.SnoozeIncident(7, "2h"); return err },
http.MethodPost, "/api/incidents/7/snooze", ""},
{"unsnooze", func(c *Client) error { return c.UnsnoozeIncident(7) },
http.MethodDelete, "/api/incidents/7/snooze", ""},
{"archive", func(c *Client) error { _, err := c.ArchiveIncident(7); return err },
http.MethodPost, "/api/incidents/7/archive", ""},
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
http.MethodDelete, "/api/incidents/7/archive", ""},
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err },
http.MethodPost, "/api/incidents/7/notes", ""},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := tt.resp
if resp == "" {
resp = `{}`
}
c, got := stub(t, http.StatusOK, resp)
if err := tt.invoke(c); err != nil {
t.Fatalf("%s: %v", tt.name, err)
}
if got.method != tt.method || got.path != tt.path {
t.Errorf("expected %s %s, got %s %s", tt.method, tt.path, got.method, got.path)
}
})
}
}
func TestListIncidents_Filters(t *testing.T) {
tests := []struct {
name string
status string
archived bool
snoozed bool
limit int
want string
}{
{"default is the open queue", "", false, false, 0, ""},
{"status", "triggered", false, false, 0, "status=triggered"},
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", "", false, true, 0, "snoozed=true"},
{"limit", "", false, false, 500, "limit=500"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
t.Fatalf("list: %v", err)
}
if got.query != tt.want {
t.Errorf("expected query %q, got %q", tt.want, got.query)
}
})
}
}
func TestClient_RequestBodies(t *testing.T) {
t.Run("assign", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.AssignIncident(1, 42); err != nil {
t.Fatalf("assign: %v", err)
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
t.Fatalf("decode body %q: %v", got.body, err)
}
if body.UserID != 42 {
t.Errorf("expected user_id 42, got %d", body.UserID)
}
})
t.Run("snooze", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SnoozeIncident(1, "90m"); err != nil {
t.Fatalf("snooze: %v", err)
}
var body struct {
Duration string `json:"duration"`
}
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
t.Fatalf("decode body %q: %v", got.body, err)
}
if body.Duration != "90m" {
t.Errorf("expected duration 90m, got %q", body.Duration)
}
})
}
// The 409 on re-resolving is the server telling the user why nothing happened,
// so the message has to survive into the error the TUI displays.
func TestClient_SurfacesServerErrorMessage(t *testing.T) {
c, _ := stub(t, http.StatusConflict, `{"error":"incident is resolved"}`)
_, err := c.ResolveIncident(1)
if err == nil {
t.Fatal("expected an error on 409")
}
if !strings.Contains(err.Error(), "incident is resolved") || !strings.Contains(err.Error(), "409") {
t.Errorf("expected status and server message in %q", err.Error())
}
}
func TestClient_ErrorWithoutBody(t *testing.T) {
c, _ := stub(t, http.StatusInternalServerError, ``)
if _, err := c.GetIncident(1); err == nil || !strings.Contains(err.Error(), "500") {
t.Errorf("expected a 500 error, got %v", err)
}
}
// Nobody on call is a normal state, not a failure.
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`)
entry, err := c.GetCurrentOnCall()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if entry != nil {
t.Errorf("expected nil entry, got %+v", entry)
}
}
// Optional fields are omitted by the server rather than sent null, so decoding
// has to leave them zero instead of failing.
func TestIncident_DecodesSparseServerShape(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{
"id": 1,
"group_key": "{}:{alertname=\"DiskFull\"}",
"title": "DiskFull (namespace=prod)",
"group_labels": {"alertname": "DiskFull", "namespace": "prod"},
"status": "triggered",
"severity": "critical",
"triggered_at": "2026-07-30T10:00:00Z"
}`)
inc, err := c.GetIncident(1)
if err != nil {
t.Fatalf("get: %v", err)
}
if inc.Title != "DiskFull (namespace=prod)" || inc.Severity != "critical" {
t.Errorf("unexpected incident %+v", inc)
}
if inc.GroupLabels["namespace"] != "prod" {
t.Errorf("expected group labels decoded, got %v", inc.GroupLabels)
}
if !inc.IsOpen() {
t.Error("an incident with no resolved_at is open")
}
if inc.IsSnoozed() {
t.Error("an incident with no snoozed_until is not snoozed")
}
if inc.AcknowledgedByID != nil || inc.AssignedToID != nil {
t.Error("expected acknowledgement and assignment to be absent")
}
}
// A snooze expires by falling into the past; the server sweeps nothing, so the
// client is what decides a stale snooze no longer counts.
func TestIncident_IsSnoozed(t *testing.T) {
past := time.Now().Add(-time.Hour)
future := time.Now().Add(time.Hour)
tests := []struct {
name string
until *time.Time
want bool
}{
{"never snoozed", nil, false},
{"snooze in the past has expired", &past, false},
{"snooze in the future holds", &future, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (Incident{SnoozedUntil: tt.until}).IsSnoozed(); got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestIncident_IsOpen(t *testing.T) {
now := time.Now()
if !(Incident{}).IsOpen() {
t.Error("no resolved_at means open")
}
if (Incident{ResolvedAt: &now}).IsOpen() {
t.Error("resolved_at means closed")
}
}
func TestAlert_DecodesIncidentLink(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{"id":3,"name":"DiskFull","status":"firing","incident_id":7}`)
a, err := c.GetAlert(3)
if err != nil {
t.Fatalf("get alert: %v", err)
}
if a.IncidentID == nil || *a.IncidentID != 7 {
t.Errorf("expected incident_id 7, got %v", a.IncidentID)
}
}
func TestListAlerts_ArchivedFilter(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListAlerts("", true, 50); err != nil {
t.Fatalf("list alerts: %v", err)
}
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
}
// MTTA and MTTR are null until something has been acknowledged or resolved. That
// is "no data", and it must not decode to a confident zero.
func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
c, _ := stub(t, http.StatusOK,
`{"total":2,"triggered":2,"acknowledged":0,"resolved":0,"mtta_seconds":null,"mttr_seconds":null}`)
stats, err := c.GetIncidentStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.Total != 2 || stats.Triggered != 2 {
t.Errorf("unexpected counts %+v", stats)
}
if stats.MTTASeconds != nil || stats.MTTRSeconds != nil {
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
}
}
+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 {
+240
View File
@@ -0,0 +1,240 @@
package tui
import (
"testing"
"time"
"github.com/charmbracelet/bubbles/table"
"github.com/yeniklas/terdut-tui/internal/api"
)
func TestNextFilter(t *testing.T) {
tests := []struct {
name string
cycle []string
current string
want string
}{
{"advances", incidentFilters, "", api.StatusTriggered},
{"advances again", incidentFilters, api.StatusTriggered, api.StatusAcknowledged},
{"wraps back to the open queue", incidentFilters, "snoozed", ""},
{"alerts advance", alertFilters, "firing", "resolved"},
{"alerts wrap", alertFilters, "archived", "firing"},
{"unknown current restarts the cycle", incidentFilters, "bogus", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextFilter(tt.cycle, tt.current); got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
// "snoozed" is a pseudo-status in the filter cycle: the server has no such
// status, it is a separate query axis.
func TestIncidentQuery(t *testing.T) {
tests := []struct {
filter string
wantStatus string
wantSnoozed bool
}{
{"", "", false},
{api.StatusTriggered, api.StatusTriggered, false},
{api.StatusResolved, api.StatusResolved, false},
{"snoozed", "", true},
}
for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
status, snoozed := incidentQuery(tt.filter)
if status != tt.wantStatus || snoozed != tt.wantSnoozed {
t.Errorf("expected (%q, %v), got (%q, %v)",
tt.wantStatus, tt.wantSnoozed, status, snoozed)
}
})
}
}
func TestFilterLabel(t *testing.T) {
if got := filterLabel(""); got != "open" {
t.Errorf("the empty filter is the open queue, got %q", got)
}
if got := filterLabel("resolved"); got != "resolved" {
t.Errorf("expected resolved, got %q", got)
}
}
func TestNoteEvents(t *testing.T) {
timeline := []api.IncidentEvent{
{Type: api.EventTriggered},
{Type: api.EventNote, Detail: "first"},
{Type: api.EventAcknowledged},
{Type: api.EventNote, Detail: "second"},
}
notes := noteEvents(timeline)
if len(notes) != 2 {
t.Fatalf("expected 2 notes, got %d", len(notes))
}
if notes[0].Detail != "first" || notes[1].Detail != "second" {
t.Errorf("notes out of order: %v", notes)
}
if len(noteEvents(nil)) != 0 {
t.Error("an empty timeline has no notes")
}
}
func TestHumanDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{5 * time.Second, "moments"},
{90 * time.Second, "1m"},
{45 * time.Minute, "45m"},
{2 * time.Hour, "2h"},
{150 * time.Minute, "2h 30m"},
{48 * time.Hour, "2d"},
{50 * time.Hour, "2d 2h"},
}
for _, tt := range tests {
if got := humanDuration(tt.d); got != tt.want {
t.Errorf("humanDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestHumanAgo_ClampsFutureToNow(t *testing.T) {
now := time.Now()
// Server and client clocks disagree often enough that this must not render
// as a negative age.
if got := humanAgo(now, now.Add(time.Hour)); got != "moments ago" {
t.Errorf("expected a future timestamp to clamp, got %q", got)
}
}
func TestHumanUntil(t *testing.T) {
now := time.Now()
if got := humanUntil(now, now.Add(2*time.Hour)); got != "in 2h" {
t.Errorf("expected 'in 2h', got %q", got)
}
if got := humanUntil(now, now.Add(-time.Minute)); got != "expired" {
t.Errorf("a deadline in the past has expired, got %q", got)
}
}
// MTTA and MTTR are nil until something has been acknowledged or resolved, and
// that has to read as "no data" rather than an instant response.
func TestHumanSeconds(t *testing.T) {
if got := humanSeconds(nil); got != "—" {
t.Errorf("expected an em dash for no data, got %q", got)
}
secs := 150.0
if got := humanSeconds(&secs); got != "2m" {
t.Errorf("expected 2m, got %q", got)
}
}
func TestIncidentRows(t *testing.T) {
future := time.Now().Add(time.Hour)
rows := incidentRows([]api.Incident{
{Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical",
AssignedTo: "admin", TriggeredAt: time.Now()},
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
})
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0][0] != "critical" || rows[0][3] != "admin" {
t.Errorf("unexpected first row %v", rows[0])
}
if rows[1][0] != "—" || rows[1][3] != "—" {
t.Errorf("missing severity and assignee should show an em dash, got %v", rows[1])
}
// bubbles' table renders plain strings, so snooze has to be marked in text.
if rows[2][2] != "triggered (zzz)" {
t.Errorf("expected a snooze marker in the status cell, got %q", rows[2][2])
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7)
rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"},
})
if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4])
}
if rows[1][4] != "—" {
t.Errorf("an alert with no incident shows an em dash, got %q", rows[1][4])
}
}
// A previous release overflowed the terminal by two columns because the padding
// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
// the window width.
func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} {
for name, cols := range map[string][]int{
"incident": widths(incidentColumns(width)),
"alert": widths(alertColumns(width)),
} {
sum := 0
for _, w := range cols {
sum += w
}
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells
if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d",
name, width, sum, padding, sum+padding)
}
}
}
}
// Narrow terminals fall back to minimum widths, which legitimately overflow;
// what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} {
for _, w := range append(widths(incidentColumns(width)), widths(alertColumns(width))...) {
if w < 1 {
t.Errorf("width %d produced a non-positive column %d", width, w)
}
}
}
}
func widths(cols []table.Column) []int {
out := make([]int, len(cols))
for i, c := range cols {
out[i] = c.Width
}
return out
}
func TestTableHeight_NeverGoesBelowOne(t *testing.T) {
if got := tableHeight(3, 10); got != 1 {
t.Errorf("expected a floor of 1, got %d", got)
}
if got := tableHeight(40, 8); got != 32 {
t.Errorf("expected 32, got %d", got)
}
}
func TestBuildScheduleDays(t *testing.T) {
monday := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
days := buildScheduleDays(monday, []api.ScheduleEntry{
{Date: "2026-07-29", Username: "alice"},
})
if len(days) != 7 {
t.Fatalf("expected a 7-day window, got %d", len(days))
}
if days[2].entry == nil || days[2].entry.Username != "alice" {
t.Errorf("expected alice on the third day, got %+v", days[2].entry)
}
if days[0].entry != nil {
t.Error("expected unassigned days to have no entry")
}
}
+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) {
+492
View File
@@ -0,0 +1,492 @@
package tui
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
// press sends one key and returns the resulting model and command. A nil command
// means the model decided to do nothing, which is what most of these tests are
// really asserting.
func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
t.Helper()
var msg tea.KeyMsg
switch key {
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
next, cmd := m.Update(msg)
return next.(Model), cmd
}
// sized returns a connected model with a usable window, which most handlers need.
func sized() Model {
m := NewModel(nil, "http://test", time.Minute)
m.width, m.height = 120, 40
m.connected = true
return m
}
// onIncident opens the incident detail view directly, skipping the fetch.
func onIncident(inc api.Incident, timeline []api.IncidentEvent) Model {
m := sized()
m.mode = modeIncidentDetail
m.selectedIncident = inc
m.timeline = timeline
m.noteCursor = -1
return m
}
func openIncidentFixture() api.Incident {
return api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered,
Severity: "critical", TriggeredAt: time.Now()}
}
func resolvedIncidentFixture() api.Incident {
now := time.Now()
source := "manual"
inc := openIncidentFixture()
inc.Status = api.StatusResolved
inc.ResolvedAt = &now
inc.ResolutionSource = &source
return inc
}
// Resolving is terminal on the server: a later occurrence opens a new incident
// rather than reopening this one. A stray keypress must not be able to do that.
func TestResolve_AsksBeforeDoingIt(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, cmd := press(t, m, "R")
if m.mode != modeConfirm {
t.Fatalf("expected a confirmation prompt, got mode %v", m.mode)
}
if m.confirmTarget != confirmResolveIncident {
t.Errorf("expected the resolve target, got %v", m.confirmTarget)
}
if cmd != nil {
t.Error("nothing should be sent to the server before confirming")
}
if !containsAll(m.confirmPrompt(), "final", "new incident") {
t.Errorf("the prompt should say resolving is final, got %q", m.confirmPrompt())
}
}
func TestResolve_CancelReturnsToDetailWithoutActing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "n")
if m.mode != modeIncidentDetail {
t.Errorf("expected to land back on the incident, got mode %v", m.mode)
}
if cmd != nil {
t.Error("cancelling must not act")
}
}
func TestResolve_ConfirmActs(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the resolve")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
// The server answers 409 on all of these; saying so up front beats a round trip.
func TestResolvedIncident_RejectsWorkflowActions(t *testing.T) {
for _, key := range []string{"a", "A", "R", "s", "z", "Z"} {
t.Run(key, func(t *testing.T) {
m := onIncident(resolvedIncidentFixture(), nil)
m, cmd := press(t, m, key)
if cmd == nil {
t.Error("expected a status message command")
}
if m.statusMsg == "" {
t.Error("expected an explanation in the status line")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay on the incident, got mode %v", m.mode)
}
})
}
}
func TestOpenIncident_AcknowledgeTwiceIsRejected(t *testing.T) {
inc := openIncidentFixture()
id := int64(2)
at := time.Now()
inc.Status = api.StatusAcknowledged
inc.AcknowledgedByID = &id
inc.AcknowledgedBy = "alice"
inc.AcknowledgedAt = &at
m, _ := press(t, onIncident(inc, nil), "a")
if !containsAll(m.statusMsg, "already acknowledged", "alice") {
t.Errorf("expected to be told who holds it, got %q", m.statusMsg)
}
}
func TestOpenIncident_UnacknowledgeRequiresAnAcknowledgement(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "A")
if m.statusMsg != "not acknowledged" {
t.Errorf("expected 'not acknowledged', got %q", m.statusMsg)
}
}
func TestOpenIncident_UnsnoozeRequiresASnooze(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "Z")
if m.statusMsg != "not snoozed" {
t.Errorf("expected 'not snoozed', got %q", m.statusMsg)
}
}
// Archiving unresolved work only hides it, so the client refuses rather than
// letting the queue be cleared by pressing x.
func TestArchive_RefusesOpenIncident(t *testing.T) {
t.Run("from the detail view", func(t *testing.T) {
m, cmd := press(t, onIncident(openIncidentFixture(), nil), "x")
if cmd == nil || m.statusMsg == "" {
t.Error("expected a refusal message")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay put, got mode %v", m.mode)
}
})
t.Run("from the queue", func(t *testing.T) {
m := sized()
m.incidents = []api.Incident{openIncidentFixture()}
m.rebuildIncidentTable()
m, _ = press(t, m, "x")
if m.statusMsg == "" {
t.Error("expected a refusal message")
}
})
}
func TestArchive_AllowedOnResolvedIncident(t *testing.T) {
m := sized()
m.incidents = []api.Incident{resolvedIncidentFixture()}
m.rebuildIncidentTable()
m, cmd := press(t, m, "x")
if cmd == nil {
t.Error("archiving a resolved incident should act")
}
if m.statusMsg != "" {
t.Errorf("expected no refusal, got %q", m.statusMsg)
}
}
func TestSnooze_PromptThenSubmit(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
if m.mode != modeSnooze {
t.Fatalf("expected the snooze prompt, got mode %v", m.mode)
}
// Typing goes to the input, not the key handler.
for _, r := range "2h" {
m, _ = press(t, m, string(r))
}
if m.snoozeInput.Value() != "2h" {
t.Fatalf("expected the typed duration, got %q", m.snoozeInput.Value())
}
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Error("expected the snooze to be sent")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestSnooze_EmptyInputDoesNothing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
m, cmd := press(t, m, "enter")
if cmd != nil {
t.Error("an empty duration should not be sent")
}
if m.mode != modeSnooze {
t.Errorf("expected to stay on the prompt, got mode %v", m.mode)
}
}
func TestNote_EscapeAbandonsWithoutPosting(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "c")
if m.mode != modeNote {
t.Fatalf("expected the note prompt, got mode %v", m.mode)
}
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("escaping must not post the note")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestNoteCursor_WrapsOverNotesOnly(t *testing.T) {
timeline := []api.IncidentEvent{
{ID: 1, Type: api.EventTriggered},
{ID: 2, Type: api.EventNote, Detail: "first"},
{ID: 3, Type: api.EventAcknowledged},
{ID: 4, Type: api.EventNote, Detail: "second"},
}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Fatalf("expected the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 1 {
t.Fatalf("expected the second note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Errorf("expected to wrap to the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "[")
if m.noteCursor != 1 {
t.Errorf("expected to wrap backwards to the last note, got %d", m.noteCursor)
}
}
func TestDeleteNote_RequiresASelection(t *testing.T) {
m := onIncident(openIncidentFixture(), []api.IncidentEvent{{Type: api.EventTriggered}})
m, _ = press(t, m, "d")
if m.mode == modeConfirm {
t.Error("nothing is selected, so there is nothing to confirm")
}
if !containsAll(m.statusMsg, "select a note") {
t.Errorf("expected guidance, got %q", m.statusMsg)
}
}
func TestDeleteNote_ConfirmsThenActs(t *testing.T) {
timeline := []api.IncidentEvent{{ID: 9, Type: api.EventNote, Detail: "hi"}}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.confirmTarget != confirmDeleteNote {
t.Fatalf("expected a delete confirmation, got mode %v target %v", m.mode, m.confirmTarget)
}
if m.pendingDeleteID != 9 {
t.Errorf("expected the selected note's id, got %d", m.pendingDeleteID)
}
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the delete")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestTab_CyclesEverySection(t *testing.T) {
m := sized()
if m.activeSection != sectionIncidents {
t.Fatal("incidents is the section the client opens on")
}
want := []section{sectionAlerts, sectionStats, sectionArchived, sectionSchedule,
sectionUsers, sectionIncidents}
for i, expected := range want {
m, _ = press(t, m, "tab")
if m.activeSection != expected {
t.Fatalf("after %d tabs expected section %v, got %v", i+1, expected, m.activeSection)
}
}
}
func TestFilter_CyclesPerSection(t *testing.T) {
m := sized()
m, _ = press(t, m, "f")
if m.incidentFilter != api.StatusTriggered {
t.Errorf("expected the incident filter to advance, got %q", m.incidentFilter)
}
m.activeSection = sectionAlerts
m, _ = press(t, m, "f")
if m.alertFilter != "resolved" {
t.Errorf("expected the alert filter to advance, got %q", m.alertFilter)
}
if m.incidentFilter != api.StatusTriggered {
t.Error("the two filters are independent")
}
}
// 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
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
// through to the incident.
func TestAlertDetail_IsReadOnly(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", Status: "firing"}
for _, key := range []string{"a", "A", "R", "c", "x", "z"} {
next, cmd := press(t, m, key)
if cmd != nil {
t.Errorf("key %q should do nothing on an alert", key)
}
if next.mode != modeAlertDetail {
t.Errorf("key %q changed mode to %v", key, next.mode)
}
}
}
func TestAlertDetail_JumpToIncident(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
t.Run("without an incident", func(t *testing.T) {
m.selectedAlert = api.Alert{ID: 3, Name: "Orphan"}
next, _ := press(t, m, "i")
if next.mode != modeAlertDetail || next.statusMsg == "" {
t.Errorf("expected a refusal, got mode %v msg %q", next.mode, next.statusMsg)
}
})
t.Run("with an incident", func(t *testing.T) {
id := int64(7)
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", IncidentID: &id}
next, cmd := press(t, m, "i")
if next.mode != modeIncidentDetail {
t.Fatalf("expected the incident view, got mode %v", next.mode)
}
if next.selectedIncident.ID != 7 {
t.Errorf("expected incident 7, got %d", next.selectedIncident.ID)
}
if cmd == nil {
t.Error("expected the incident to be fetched")
}
})
}
// A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate}
for _, md := range modal {
m := sized()
m.mode = md
if cmd := m.refreshActiveSection(); cmd != nil {
t.Errorf("mode %v should not auto-refresh", md)
}
}
m := sized()
if cmd := m.refreshActiveSection(); cmd == nil {
t.Error("the dashboard should auto-refresh")
}
}
func TestIncidentsFetched_ClearsLoading(t *testing.T) {
m := sized()
m.loading = true
next, _ := m.Update(incidentsFetchedMsg{incidents: []api.Incident{openIncidentFixture()}})
got := next.(Model)
if got.loading {
t.Error("expected loading to clear")
}
if len(got.incidents) != 1 {
t.Errorf("expected the incidents stored, got %d", len(got.incidents))
}
}
// A note deleted elsewhere must not leave the cursor pointing past the end.
func TestIncidentDetailFetched_ClampsNoteCursor(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m.noteCursor = 3
next, _ := m.Update(incidentDetailFetchedMsg{
incident: openIncidentFixture(),
timeline: []api.IncidentEvent{{Type: api.EventTriggered}},
})
if got := next.(Model).noteCursor; got != -1 {
t.Errorf("expected the cursor reset, got %d", got)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}
+43 -31
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,12 +359,24 @@ 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()
} }
// line renders s in a style and terminates it.
//
// The newline has to stay outside Render: lipgloss pads every line of a styled
// block out to its widest line, so a trailing newline inside the block produces
// a second line made entirely of padding, and whatever is written next starts
// after that padding instead of at the left margin.
func line(style lipgloss.Style, s string) string {
return style.Render(s) + "\n"
}
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
@@ -391,7 +403,7 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AssignedTo != "" { if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo)))
} else { } else {
b.WriteString(styleMuted.Render(" Assigned: nobody\n")) b.WriteString(line(styleMuted, " Assigned: nobody"))
} }
if inc.AcknowledgedByID != nil { if inc.AcknowledgedByID != nil {
@@ -399,14 +411,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil { if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
} }
b.WriteString(styleResolved.Render( b.WriteString(line(styleResolved,
fmt.Sprintf(" Acked: %s%s\n", inc.AcknowledgedBy, ackAt))) fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else { } else {
b.WriteString(styleMuted.Render(" Acked: not acknowledged\n")) b.WriteString(line(styleMuted, " Acked: not acknowledged"))
} }
if inc.IsSnoozed() { if inc.IsSnoozed() {
b.WriteString(styleSnoozed.Render(fmt.Sprintf(" Snoozed: until %s (%s)\n", b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
} }
@@ -419,8 +431,8 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
} }
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
b.WriteString(styleMuted.Render(" Archived: " + b.WriteString(line(styleMuted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC") + "\n")) inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
} }
b.WriteString("\n") b.WriteString("\n")
@@ -436,7 +448,7 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
// Member alerts // Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 { if len(inc.Alerts) == 0 {
b.WriteString(styleMuted.Render(" No alerts.\n")) b.WriteString(line(styleMuted, " No alerts."))
} else { } else {
for _, a := range inc.Alerts { for _, a := range inc.Alerts {
marker := styleFiring.Render("●") marker := styleFiring.Render("●")
@@ -458,7 +470,7 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
notes := noteEvents(timeline) notes := noteEvents(timeline)
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
if len(timeline) == 0 { if len(timeline) == 0 {
b.WriteString(styleMuted.Render(" Nothing recorded yet.\n")) b.WriteString(line(styleMuted, " Nothing recorded yet."))
} else { } else {
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
@@ -584,7 +596,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it"))) styleMuted.Render("press i to open it")))
} else { } else {
b.WriteString(styleMuted.Render(" Incident: none\n")) b.WriteString(line(styleMuted, " Incident: none"))
} }
b.WriteString("\n") b.WriteString("\n")
@@ -606,8 +618,8 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
// Alerts carry no workflow state: it all lives on the incident. // Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width)) b.WriteString(divider("", width))
b.WriteString(styleMuted.Render( b.WriteString(line(styleMuted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.\n")) " Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
return b.String() return b.String()
} }
@@ -627,7 +639,7 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
// Response times first: they are what a rota is actually judged on. // Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width)) b.WriteString(divider("Incident Response", width))
if incidents == nil { if incidents == nil {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} else { } else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) styleBold.Render(fmt.Sprintf("%d", incidents.Total))))
@@ -642,14 +654,14 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) styleBold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(styleMuted.Render(" (— means nothing has been acknowledged or resolved yet)\n")) b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)"))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Top Alerts", width)) b.WriteString(divider("Top Alerts", width))
if len(top) == 0 { if len(top) == 0 {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} else { } else {
maxCount := top[0].Count maxCount := top[0].Count
for i, a := range top { for i, a := range top {
@@ -672,7 +684,7 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
} }
} else { } else {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} }
b.WriteString("\n") b.WriteString("\n")
@@ -689,7 +701,7 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
} }
} else { } else {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} }
return b.String() return b.String()
@@ -723,7 +735,7 @@ func (m Model) renderUserCreate() string {
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username))
warning := styleMuted.Render(" Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.\n") warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" + options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" + styleAccent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n" styleAccent.Render(" r") + " · revoke a key by ID\n"
@@ -757,7 +769,7 @@ func (m Model) renderAPIKeyReveal() string {
func (m Model) renderAPIKeyRevokeByID() string { func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username))
hint := styleMuted.Render(" Enter the integer key ID (shown when the key was created).\n") hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ") label := styleSelected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
} }
+308
View File
@@ -0,0 +1,308 @@
package tui
import (
"regexp"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
// ansi matches the escape sequences lipgloss emits when it decides the output
// supports colour, so assertions can be made against the text alone.
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plain(s string) string { return ansi.ReplaceAllString(s, "") }
func mustContain(t *testing.T, got string, wants ...string) {
t.Helper()
got = plain(got)
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("expected output to contain %q\n--- got ---\n%s", w, got)
}
}
}
func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
now := time.Now()
ackID := int64(1)
alertID := int64(3)
inc := api.Incident{
ID: 1, Title: "DiskFull (namespace=prod)", Status: api.StatusAcknowledged,
Severity: "critical",
GroupLabels: map[string]string{"alertname": "DiskFull", "namespace": "prod"},
TriggeredAt: now.Add(-2 * time.Hour),
AssignedTo: "admin", AcknowledgedByID: &ackID, AcknowledgedBy: "admin",
AcknowledgedAt: &now,
Alerts: []api.Alert{
{ID: 3, Name: "DiskFull", Status: "firing",
Labels: map[string]string{"instance": "node-1"}, ReceivedAt: now},
{ID: 4, Name: "DiskFull", Status: "resolved",
Labels: map[string]string{"instance": "node-2"}, ReceivedAt: now},
},
}
timeline := []api.IncidentEvent{
{Type: api.EventTriggered, CreatedAt: now},
{Type: api.EventAssigned, Username: "admin", CreatedAt: now},
{Type: api.EventAlertAdded, AlertID: &alertID, CreatedAt: now},
{Type: api.EventAcknowledged, Username: "admin", CreatedAt: now},
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
}
out := buildIncidentDetailContent(inc, timeline, -1, 110)
mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin",
"Grouped By", "namespace", "prod",
"Alerts (2)", "node-1", "node-2",
"Timeline (5 events, 1 notes)",
"Incident opened", "Assigned to admin", "Alert #3 joined", "Acknowledged by admin",
"admin wrote", "draining node-2",
)
}
func TestIncidentDetail_ShowsSnooze(t *testing.T) {
future := time.Now().Add(2 * time.Hour)
inc := api.Incident{
Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &future,
}
// The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
}
// An expired snooze is not a snooze, so it must not be reported as one.
func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
past := time.Now().Add(-time.Hour)
inc := api.Incident{
Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past,
}
if strings.Contains(plain(buildIncidentDetailContent(inc, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered")
}
}
func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
now := time.Now()
source := "manual"
inc := api.Incident{
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source,
}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110),
"RESOLVED", "Resolved:", "manual")
}
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "nobody", "not acknowledged")
}
func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "Nothing recorded yet")
}
func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
now := time.Now()
timeline := []api.IncidentEvent{
{Type: api.EventNote, Username: "admin", Detail: "first", CreatedAt: now},
{Type: api.EventNote, Username: "alice", Detail: "second", CreatedAt: now},
}
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(inc, timeline, 1, 110))
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line)
}
if strings.Contains(line, "admin wrote") && strings.HasPrefix(line, "> ") {
t.Errorf("expected the unselected note unmarked, got %q", line)
}
}
}
func TestIncidentStatusLabel(t *testing.T) {
future := time.Now().Add(time.Hour)
tests := []struct {
name string
inc api.Incident
want string
}{
{"triggered", api.Incident{Status: api.StatusTriggered}, "● TRIGGERED"},
{"acknowledged", api.Incident{Status: api.StatusAcknowledged}, "◐ ACKNOWLEDGED"},
{"resolved", api.Incident{Status: api.StatusResolved}, "✓ RESOLVED"},
{"snoozed", api.Incident{Status: api.StatusTriggered, SnoozedUntil: &future},
"● TRIGGERED (snoozed)"},
// A status this client does not know about still has to render.
{"unknown", api.Incident{Status: "escalated"}, "ESCALATED"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := incidentStatusLabel(tt.inc); got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
// The server may add event types after this client ships. An unknown one must
// still appear on the timeline rather than silently vanishing.
func TestEventLabel_UnknownTypeFallsBackToItsName(t *testing.T) {
got := eventLabel(api.IncidentEvent{Type: "escalated", Detail: "to sre-oncall"})
mustContain(t, got, "escalated", "to sre-oncall")
}
func TestEventLabel_KnownTypes(t *testing.T) {
alertID := int64(9)
tests := []struct {
event api.IncidentEvent
want string
}{
{api.IncidentEvent{Type: api.EventTriggered}, "Incident opened"},
{api.IncidentEvent{Type: api.EventAlertAdded, AlertID: &alertID}, "Alert #9 joined"},
{api.IncidentEvent{Type: api.EventAlertResolved, AlertID: &alertID}, "Alert #9 resolved"},
{api.IncidentEvent{Type: api.EventAcknowledged, Username: "bo"}, "Acknowledged by bo"},
{api.IncidentEvent{Type: api.EventAssigned, Username: "bo"}, "Assigned to bo"},
{api.IncidentEvent{Type: api.EventSnoozed, Detail: "2026-08-01T00:00:00Z"},
"Snoozed until 2026-08-01T00:00:00Z"},
{api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"},
// No user means the server closed it via the alert cascade.
{api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"},
}
for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) {
mustContain(t, eventLabel(tt.event), tt.want)
})
}
}
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
now := time.Now()
id := int64(7)
alert := api.Alert{
ID: 3, Name: "DiskFull", Status: "firing", StartsAt: now.Add(-time.Hour),
ReceivedAt: now, IncidentID: &id,
Labels: map[string]string{"instance": "node-1", "severity": "critical"},
Annotations: map[string]string{"summary": "disk 90%"},
}
mustContain(t, buildAlertDetailContent(alert, 110),
"DiskFull", "FIRING", "Incident:", "#7", "press i to open it",
"instance", "node-1", "summary", "disk 90%",
"Alerts are read-only")
}
func TestAlertDetail_NoIncident(t *testing.T) {
alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()}
mustContain(t, buildAlertDetailContent(alert, 110), "Incident:", "none")
}
func TestAlertDetail_ShowsResolutionSource(t *testing.T) {
source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
ResolutionSource: &source}
mustContain(t, buildAlertDetailContent(alert, 110), "RESOLVED", "expiry")
}
// Null MTTA means nothing has been acknowledged, which is a different claim
// from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) {
stats := &api.IncidentStats{Total: 2, Triggered: 2}
out := buildStatsContent(stats, nil, nil, nil, 110)
mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—",
"nothing has been acknowledged or resolved yet")
}
func TestStats_RendersAverages(t *testing.T) {
mtta, mttr := 150.0, 3600.0
stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr}
out := buildStatsContent(stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110)
mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull")
}
func TestStats_HandlesNoIncidentData(t *testing.T) {
mustContain(t, buildStatsContent(nil, nil, nil, nil, 110), "Incident Response", "No data")
}
func TestView_TabsAndDashboardRender(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{
ID: 1, Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical",
AssignedTo: "admin", TriggeredAt: time.Now(),
}}
m.incidentStats = &api.IncidentStats{Triggered: 1}
m.rebuildIncidentTable()
mustContain(t, m.View(),
"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users",
"Triggered: 1", "filter: open",
"DiskFull", "critical", "admin",
"enter·detail")
}
func TestView_EmptyStates(t *testing.T) {
m := sized()
m.loading = false
mustContain(t, m.View(), "No open incidents.")
m.activeSection = sectionArchived
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
m.err = errFixture{}
mustContain(t, m.View(), "Error:", "Press r to retry")
}
// The footer is the only place the terminal states are explained, so the
// destructive one has to be visible before it is pressed.
func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) {
open := onIncident(openIncidentFixture(), nil)
mustContain(t, open.renderFooter(), "R·resolve", "z·snooze", "a·ack")
closed := onIncident(resolvedIncidentFixture(), nil)
if strings.Contains(plain(closed.renderFooter()), "R·resolve") {
t.Error("a resolved incident should not offer resolve")
}
mustContain(t, closed.renderFooter(), "x·archive", "c·note")
}
func TestView_ZeroWidthRendersNothing(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute)
if m.View() != "" {
t.Error("expected no output before the first window size message")
}
}
type errFixture struct{}
func (errFixture) Error() string { return "connection refused" }