5 Commits

Author SHA1 Message Date
Niklas Ye e336aeea97 feat: reassign on-call days and weeks to another person
Release / test (push) Failing after 4s
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
Assigning over a day somebody else held did nothing but flash a 409 for
three seconds. The server holds one person per date and refused any that
was taken, all-or-nothing, so pressing W on a week where a single day was
already assigned placed none of the other six either. The only way
through was d on each day first — seven delete-and-confirm cycles to move
one week.

The clash is already on screen, so it is found before the request rather
than read back out of an error: the picker hands off to a confirmation
naming who loses the days and how many there are, and accepting sends the
whole selection with replace, which terdut-server v0.8.0 added. One
question to move a week, and nobody's shift moves without somebody being
asked. A day nobody holds still assigns with no prompt at all.

Reassigning somebody to a day they already hold raises no prompt, since
it takes nothing from anyone, but it does send replace: the server
rejects any date that exists, so without it a harmless no-op would fail.
2026-08-07 14:04:37 +02:00
Niklas Ye 85ad2d65ee feat: ntfy topics per user, and notifications on the timeline
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
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
terdut-server pages the on-call person through ntfy, but none of it was
reachable from here. A user's topic could only be set with curl, so a
new user silently got no pages and quietly fell back to the shared
fallback topic — which carries no Acknowledge button. And nothing said
whether anybody had been paged at all.

The Users section grows an Ntfy Topic column and t to edit it,
prefilled with the current value. Submitting an empty field clears the
topic rather than being rejected as a mistake: clearing is how somebody
is taken off their own topic, and it is what the server means by an
empty string. Nil and empty arrive as the same thing, because the
server stores a blank topic as NULL, so User.Topic flattens the two
instead of leaving every caller to.

The incident timeline renders the server's notified and notify_failed
events. No new fetch — the timeline endpoint already carried them, and
unknown types already fell through to a generic label; this is about
saying something useful. An event with no user means the fallback
topic, not "the server acted", which is the difference between somebody
having been paged and the rota having been empty.

Both need terdut-server v0.6.0 or later, and the timeline entries a
server newer than that. Against an older one the column stays empty and
editing a topic reports the server's 404, which is the honest answer.
2026-08-07 13:41:07 +02:00
Niklas Ye f75ae60e74 fix: truncate in runes rather than bytes
truncate measures and slices by byte, so a string cut inside a
multi-byte rune both mis-measures the fixed-width column it is being
laid out against and emits a broken character. Everything it is handed
is server-supplied — alert names, label values, annotations — and none
of that is guaranteed to be ASCII.

Identical behaviour for the ASCII case.
2026-08-07 13:31:51 +02:00
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
12 changed files with 922 additions and 102 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
+34 -3
View File
@@ -8,11 +8,11 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing - **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive - **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **Timeline** — the full history of an incident, system events and notes together - **Timeline** — the full history of an incident, system events, pages and notes together
- **Alert feed** — the raw read-only alerts underneath, each linked to its incident - **Alert feed** — the raw read-only alerts underneath, each linked to its incident
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries - **On-call schedule** — visual calendar of who is on duty, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys - **User management** — add and remove users, manage API keys, set each user's ntfy topic
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API; > Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API;
> use terdut-tui v0.3.x with those. > use terdut-tui v0.3.x with those.
@@ -37,6 +37,22 @@ Two behaviours worth knowing before you press a key:
- **Snooze is the "not now" button.** It hides an incident from the default queue - **Snooze is the "not now" button.** It hides an incident from the default queue
without closing it, and expires on its own. without closing it, and expires on its own.
## Push notifications
When the server is configured for ntfy, an incident that opens pages whoever is
on call. Each user has their own topic, shown as a column in the Users section
and edited with `t`. A user with no topic falls back to the server's shared
fallback topic, which carries **no Acknowledge button** — the topic is shared, so
a button on it would let any subscriber acknowledge as somebody else.
Every delivery lands on the incident's timeline: `Notified <user> (triggered)`
when ntfy accepted the page, and `Notification to <user> failed` when it ran out
of retries. That second one is the one to look for when nobody's phone rang.
Editing topics needs terdut-server **v0.6.0 or later**; the timeline entries need
**v0.7.0 or later**. Against an older server the topic column stays empty and
editing one reports the server's 404.
## Installation ## Installation
Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source:
@@ -78,9 +94,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 +125,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 |
@@ -116,10 +139,18 @@ Schedule section:
| `d` | Remove the assignment | | `d` | Remove the assignment |
| `←` / `→` | Shift the week window | | `←` / `→` | Shift the week window |
One person holds a given day. Assigning over days somebody else already has
asks first — naming them and how many days are being taken — and moves the whole
selection at once when you accept, so reassigning a week is one confirmation
rather than seven deletions. Taking somebody's shift needs terdut-server
**v0.8.0 or later**; against an older server the assignment is refused with
`date already assigned`.
Users section: Users section:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `n` | Create a user | | `n` | Create a user |
| `t` | Edit the user's ntfy topic — submit empty to clear it |
| `d` | Delete a user | | `d` | Delete a user |
| `k` | API keys for the selected user | | `k` | API keys for the selected user |
+25 -2
View File
@@ -358,11 +358,17 @@ func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
return &entry, nil return &entry, nil
} }
func (c *Client) AssignSchedule(userID int64, dates []string) ([]ScheduleEntry, error) { // AssignSchedule puts one user on call for the given dates.
//
// The server holds one person per day and refuses a date somebody already has,
// so replace is what takes a shift off its current holder. It is all-or-nothing
// either way: a week of free and taken days moves as a unit, or not at all.
func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct { body := struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Dates []string `json:"dates"` Dates []string `json:"dates"`
}{UserID: userID, Dates: dates} Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body) req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -401,6 +407,23 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user) return &user, c.do(req, &user)
} }
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
//
// An empty topic clears it: the server stores NULL, and that user's incidents
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
}
func (c *Client) DeleteUser(id int64) error { func (c *Client) DeleteUser(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id)) req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
if err != nil { if err != nil {
+61
View File
@@ -83,6 +83,8 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""}, http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""}, http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
http.MethodPut, "/api/users/7/notify", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -163,6 +165,65 @@ func TestClient_RequestBodies(t *testing.T) {
t.Errorf("expected duration 90m, got %q", body.Duration) t.Errorf("expected duration 90m, got %q", body.Duration)
} }
}) })
// replace is what takes a day off its current holder, so it has to reach the
// wire when asked for — and stay off it when not.
t.Run("assign schedule", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
t.Errorf("unexpected body %q", got.body)
}
})
t.Run("assign schedule with replace", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil {
t.Fatalf("assign: %v", err)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
t.Errorf("unexpected body %q", got.body)
}
})
t.Run("set notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
t.Fatalf("set notify target: %v", err)
}
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body)
}
})
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
}
})
}
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
var users []User
if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
t.Fatalf("decode: %v", err)
}
want := []string{"", "", "terdut-c"}
for i, u := range users {
if got := u.Topic(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
}
}
} }
// The 409 on re-resolving is the server telling the user why nothing happened, // The 409 on re-resolving is the server telling the user why nothing happened,
+22
View File
@@ -95,6 +95,13 @@ const (
EventUnsnoozed = "unsnoozed" EventUnsnoozed = "unsnoozed"
EventResolved = "resolved" EventResolved = "resolved"
EventNote = "note" EventNote = "note"
// Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person.
EventNotified = "notified"
EventNotifyFailed = "notify_failed"
) )
// IncidentEvent is one entry in an incident's timeline. An empty Username means // IncidentEvent is one entry in an incident's timeline. An empty Username means
@@ -158,6 +165,21 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback
// topic, which carries no Acknowledge button.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
}
// Topic reads the user's ntfy topic, flattening the nil and empty cases the
// server treats alike.
func (u User) Topic() string {
if u.NtfyTopic == nil {
return ""
}
return *u.NtfyTopic
} }
type APIKey struct { type APIKey struct {
+66 -9
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,9 +38,9 @@ const (
modeNote modeNote
modeSnooze modeSnooze
modeConfirm modeConfirm
modeStats
modeUserPicker modeUserPicker
modeUserCreate modeUserCreate
modeUserNotifyEdit
modeAPIKeyMenu modeAPIKeyMenu
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
@@ -53,6 +54,7 @@ const (
confirmResolveIncident confirmResolveIncident
confirmDeleteScheduleEntry confirmDeleteScheduleEntry
confirmDeleteUser confirmDeleteUser
confirmReassignSchedule
) )
// pickerTarget says what the user picker is choosing a person for. // pickerTarget says what the user picker is choosing a person for.
@@ -142,6 +144,18 @@ type scheduleDay struct {
entry *api.ScheduleEntry entry *api.ScheduleEntry
} }
// pendingAssign is an on-call assignment held back by the reassignment
// confirmation, because some of its dates belong to somebody else.
type pendingAssign struct {
userID int64
username string
dates []string
// taken are the dates currently held by other people, and holders the
// distinct names holding them — both only for wording the prompt.
taken []string
holders []string
}
type Model struct { type Model struct {
client *api.Client client *api.Client
serverURL string serverURL string
@@ -193,16 +207,17 @@ type Model struct {
confirmTarget confirmTarget confirmTarget confirmTarget
pendingDeleteID int64 // note event ID pendingDeleteID int64 // note event ID
pendingDeleteEntry *api.ScheduleEntry pendingDeleteEntry *api.ScheduleEntry
pendingAssign *pendingAssign
// 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
@@ -224,6 +239,7 @@ type Model struct {
selectedUser api.User selectedUser api.User
userFormInputs [2]textinput.Model userFormInputs [2]textinput.Model
userFormFocus int userFormFocus int
ntfyTopicInput textinput.Model
apiKeyNameInput textinput.Model apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
@@ -253,6 +269,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
@@ -269,6 +289,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email" emailIn.Placeholder = "email"
emailIn.CharLimit = 128 emailIn.CharLimit = 128
topicIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it"
topicIn.CharLimit = 128
keyNameIn := textinput.New() keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)" keyNameIn.Placeholder = "key name (e.g. laptop)"
keyNameIn.CharLimit = 64 keyNameIn.CharLimit = 64
@@ -298,6 +322,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,
@@ -305,6 +330,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
userPickerTable: pickerT, userPickerTable: pickerT,
userManageTable: manageT, userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn}, userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
help: help.New(), help: help.New(),
@@ -366,7 +392,11 @@ func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width)) m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users)) rows := make([]table.Row, len(m.users))
for i, u := range m.users { for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")} topic := u.Topic()
if topic == "" {
topic = "—"
}
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
} }
m.userManageTable.SetRows(rows) m.userManageTable.SetRows(rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10)) m.userManageTable.SetHeight(tableHeight(m.height, 10))
@@ -397,6 +427,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 {
@@ -489,13 +527,16 @@ func userPickerColumns(width int) []table.Column {
func userManageColumns(width int) []table.Column { func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
emailW := width - usernameW - createdW - 8 topicW := 22
// 8 = bubbles' Padding(0, 1) on each of the four cells.
emailW := width - usernameW - topicW - createdW - 8
if emailW < 15 { if emailW < 15 {
emailW = 15 emailW = 15
} }
return []table.Column{ return []table.Column{
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
} }
@@ -839,9 +880,9 @@ func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
} }
} }
func assignScheduleCmd(client *api.Client, userID int64, dates []string, from, to time.Time) tea.Cmd { func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates); err != nil { if _, err := client.AssignSchedule(userID, dates, replace); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
@@ -896,6 +937,22 @@ func createUserCmd(client *api.Client, username, email string) tea.Cmd {
} }
} }
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when
// topic is empty. It re-lists afterwards so the table shows what the server
// stored rather than what was typed.
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
return func() tea.Msg {
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd { func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteUser(userID); err != nil { if err := client.DeleteUser(userID); err != nil {
+132 -1
View File
@@ -172,6 +172,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
} }
} }
// A user with no ntfy topic gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas"
empty := ""
m := NewModel(nil, "http://test", time.Minute)
m.width, m.height = 120, 40
m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic},
{ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
}
}
// A previous release overflowed the terminal by two columns because the padding // 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 // budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
// the window width. // the window width.
@@ -191,6 +217,17 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
name, width, sum, padding, sum+padding) name, width, sum, padding, sum+padding)
} }
} }
// The users table is four cells, so its padding budget differs.
sum := 0
for _, w := range widths(userManageColumns(width)) {
sum += w
}
const userPadding = 8
if sum+userPadding != width {
t.Errorf("user columns at width %d sum to %d+%d = %d",
width, sum, userPadding, sum+userPadding)
}
} }
} }
@@ -198,7 +235,9 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
// what must not happen is a negative or zero column. // what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) { func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} { for _, width := range []int{20, 40, 60} {
for _, w := range append(widths(incidentColumns(width)), widths(alertColumns(width))...) { cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols {
if w < 1 { if w < 1 {
t.Errorf("width %d produced a non-positive column %d", width, w) t.Errorf("width %d produced a non-positive column %d", width, w)
} }
@@ -238,3 +277,95 @@ func TestBuildScheduleDays(t *testing.T) {
t.Error("expected unassigned days to have no entry") t.Error("expected unassigned days to have no entry")
} }
} }
// ── Schedule reassignment ─────────────────────────────────────────────────
// scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute)
m.width, m.height = 120, 40
m.connected = true
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries
m.scheduleDays = buildScheduleDays(m.scheduleWindow, entries)
m.rebuildScheduleTable()
return m
}
func TestScheduleConflicts(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-28", UserID: 3, Username: "sam"},
{ID: 3, Date: "2026-07-29", UserID: 2, Username: "alex"},
})
week := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"}
// Assigning alex: the days niklas and sam hold are conflicts, the day alex
// already holds is not, and the free day is not.
taken, holders := m.scheduleConflicts(week, 2)
if len(taken) != 2 || taken[0] != "2026-07-27" || taken[1] != "2026-07-28" {
t.Errorf("expected the two other people's days, got %v", taken)
}
if len(holders) != 2 || holders[0] != "niklas" || holders[1] != "sam" {
t.Errorf("expected both holders named once, got %v", holders)
}
}
// Reassigning somebody to a day they already hold takes nothing from anyone, so
// it must not raise a prompt — but it still needs replace, because the server
// rejects any date that already exists.
func TestScheduleConflicts_OwnDayIsNotAConflict(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 2, Username: "alex"},
})
dates := []string{"2026-07-27"}
if taken, _ := m.scheduleConflicts(dates, 2); len(taken) != 0 {
t.Errorf("expected no conflict on the user's own day, got %v", taken)
}
if !m.scheduleOccupied(dates) {
t.Error("expected the day to still count as occupied, so replace is sent")
}
}
func TestScheduleOccupied_FreeDays(t *testing.T) {
m := scheduledWeek(nil)
if m.scheduleOccupied([]string{"2026-07-27", "2026-07-28"}) {
t.Error("expected an empty rota to need no replace")
}
}
func TestDayCount(t *testing.T) {
tests := []struct {
taken, total int
want string
}{
{1, 1, "This day is"},
{7, 7, "All 7 days are"},
{3, 7, "3 of 7 days are"},
}
for _, tt := range tests {
if got := dayCount(tt.taken, tt.total); got != tt.want {
t.Errorf("dayCount(%d, %d) = %q, want %q", tt.taken, tt.total, got, tt.want)
}
}
}
func TestJoinNames(t *testing.T) {
tests := []struct {
names []string
want string
}{
{nil, "somebody else"},
{[]string{"niklas"}, "niklas"},
{[]string{"niklas", "alex"}, "niklas and alex"},
{[]string{"niklas", "alex", "sam"}, "niklas, alex and sam"},
}
for _, tt := range tests {
if got := joinNames(tt.names); got != tt.want {
t.Errorf("joinNames(%v) = %q, want %q", tt.names, got, tt.want)
}
}
}
+147 -42
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)
@@ -258,6 +258,12 @@ 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 modeUserNotifyEdit:
var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyCreate: case modeAPIKeyCreate:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg) m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
@@ -286,6 +292,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,12 +330,12 @@ 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:
return m.handleUserCreateKey(msg) return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
@@ -482,12 +493,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
@@ -500,6 +505,23 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeUserCreate m.mode = modeUserCreate
return m, nil return m, nil
case "t":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
// Prefilled with what they have, so editing a topic does not mean
// retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
return m, nil
case "k": case "k":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 { if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil return m, nil
@@ -524,6 +546,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 +704,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 +757,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
@@ -801,6 +822,7 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
m.pendingDeleteID = 0 m.pendingDeleteID = 0
m.pendingDeleteEntry = nil m.pendingDeleteEntry = nil
m.pendingAssign = nil
return m, nil return m, nil
} }
@@ -830,30 +852,22 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
m.usersLoading = true m.usersLoading = true
return m, deleteUserCmd(m.client, userID) return m, deleteUserCmd(m.client, userID)
case confirmReassignSchedule:
p := m.pendingAssign
m.mode = modeDashboard
m.pendingAssign = nil
if p == nil {
return m, nil
}
m.scheduleLoading = true
return m, assignScheduleCmd(m.client, p.userID, p.dates, true,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
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) {
@@ -897,15 +911,84 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} else { } else {
dates = []string{d.Format("2006-01-02")} dates = []string{d.Format("2006-01-02")}
} }
// The server refuses a date somebody else holds, so ask before taking
// it rather than letting the request come back 409. The answer is
// already on screen — no round trip is needed to work out who loses
// their shift.
taken, holders := m.scheduleConflicts(dates, user.ID)
if len(taken) > 0 {
m.pendingAssign = &pendingAssign{
userID: user.ID,
username: user.Username,
dates: dates,
taken: taken,
holders: holders,
}
m.confirmTarget = confirmReassignSchedule
m.mode = modeConfirm
return m, nil
}
m.mode = modeDashboard m.mode = modeDashboard
m.scheduleLoading = true m.scheduleLoading = true
return m, assignScheduleCmd(m.client, user.ID, dates, // Nobody else loses anything, but the server rejects any date that
// already exists — including days this same person already holds, which
// is a no-op worth letting through silently.
return m, assignScheduleCmd(m.client, user.ID, dates, m.scheduleOccupied(dates),
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
return m, nil return m, nil
} }
// scheduleConflicts reports which of dates are already held by somebody other
// than newUserID, and the distinct names holding them.
//
// Days the target already owns are not conflicts — reassigning somebody to
// their own shift takes nothing from anyone, and prompting for it would be
// noise. The server still needs replace for those, since it rejects any date
// that exists.
func (m Model) scheduleConflicts(dates []string, newUserID int64) (taken, holders []string) {
held := make(map[string]api.ScheduleEntry, len(m.scheduleDays))
for _, d := range m.scheduleDays {
if d.entry != nil {
held[d.entry.Date] = *d.entry
}
}
seen := make(map[string]bool)
for _, date := range dates {
e, ok := held[date]
if !ok || e.UserID == newUserID {
continue
}
taken = append(taken, date)
if !seen[e.Username] {
seen[e.Username] = true
holders = append(holders, e.Username)
}
}
return taken, holders
}
// scheduleOccupied reports whether any of dates already has an entry at all,
// including one belonging to the incoming user. That is what decides whether
// the request needs replace, as opposed to whether it needs confirming.
func (m Model) scheduleOccupied(dates []string) bool {
held := make(map[string]bool, len(m.scheduleDays))
for _, d := range m.scheduleDays {
if d.entry != nil {
held[d.entry.Date] = true
}
}
for _, date := range dates {
if held[date] {
return true
}
}
return false
}
// ── User management ─────────────────────────────────────────────────────────── // ── User management ───────────────────────────────────────────────────────────
func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
@@ -939,6 +1022,28 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// handleUserNotifyEditKey edits one user's ntfy topic.
//
// Unlike the other forms here, an empty value is not a mistake to reject: it is
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
return m, nil
case "enter":
topic := strings.TrimSpace(m.ntfyTopicInput.Value())
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic)
}
return m, nil
}
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
+232 -21
View File
@@ -309,13 +309,200 @@ func TestDeleteNote_ConfirmsThenActs(t *testing.T) {
} }
} }
// ── Schedule reassignment ─────────────────────────────────────────────────
// pickingOnCall opens the user picker for the schedule day at dayIndex, which
// is where a reassignment actually starts.
func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
m := scheduledWeek(entries)
m.users = []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"},
}
m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week
m.pickerTarget = pickerSchedule
m.mode = modeUserPicker
m.userPickerTable.SetCursor(1) // alex
return m
}
// The bug: a day somebody already holds could not be handed to anybody else.
// The server refuses it, so the TUI has to ask first and then say so.
func TestSchedule_ReassigningATakenDayAsksFirst(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeConfirm || m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected a reassignment confirmation, got mode %v target %v",
m.mode, m.confirmTarget)
}
if cmd != nil {
t.Error("expected nothing sent to the server before confirming")
}
mustContain(t, m.confirmPrompt(), "This day is assigned to niklas", "Reassign to alex?")
}
func TestSchedule_ReassignConfirmedSends(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Fatal("expected the confirmed reassignment to be sent")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment cleared")
}
}
// Declining must leave the rota alone — that is the whole point of the guard.
func TestSchedule_ReassignDeclinedSendsNothing(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "n")
if cmd != nil {
t.Error("expected nothing sent when the reassignment is declined")
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment discarded")
}
}
// A free day is the path that always worked, and must not grow a prompt.
func TestSchedule_AssigningAFreeDayDoesNotAsk(t *testing.T) {
m := pickingOnCall(nil, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeDashboard {
t.Errorf("expected no prompt for a free day, got mode %v", m.mode)
}
if cmd == nil {
t.Error("expected the assignment to be sent straight away")
}
}
// The week case is the one that was worst: a single taken day rejected all
// seven. One prompt now covers the lot, and it says how much is being taken.
func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-28", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-30", UserID: 3, Username: "sam"},
}, 0, true)
m, _ = press(t, m, "enter")
if m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected one confirmation for the week, got target %v", m.confirmTarget)
}
if got := len(m.pendingAssign.dates); got != 7 {
t.Errorf("expected all 7 days in the assignment, got %d", got)
}
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
m := sized()
m.activeSection = sectionUsers
m.users = users
m.rebuildUserManageTable()
return m
}
func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
{ID: 2, Username: "alex", Email: "alex@example.com"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
}
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
}
// A user with no topic opens an empty field rather than the previous user's.
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1)
m, _ = press(t, m, "t")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
}
}
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("expected escape to save nothing")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
t.Error("expected t to do nothing outside the Users section")
}
}
func TestTab_CyclesEverySection(t *testing.T) { func TestTab_CyclesEverySection(t *testing.T) {
m := sized() m := sized()
if m.activeSection != sectionIncidents { if m.activeSection != sectionIncidents {
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 +528,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)
} }
m, _ = press(t, m, "esc") 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 { if m.mode != modeIncidentDetail {
t.Errorf("expected the incident, got mode %v", m.mode) 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
+101 -15
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,12 +69,12 @@ 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:
return m.renderUserCreate() return m.renderUserCreate()
case modeUserNotifyEdit:
return m.renderUserNotifyEdit()
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.renderAPIKeyMenu() return m.renderAPIKeyMenu()
case modeAPIKeyCreate: case modeAPIKeyCreate:
@@ -99,12 +100,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 +116,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")
@@ -131,6 +129,9 @@ func (m Model) renderFooter() string {
case modeUserCreate: case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel") return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit:
return withStatus(" enter·save esc·cancel (empty clears the topic)")
case modeAPIKeyMenu: case modeAPIKeyMenu:
return withStatus(" n·new key r·revoke by ID esc·back") return withStatus(" n·new key r·revoke by ID esc·back")
@@ -146,15 +147,17 @@ 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:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
case sectionUsers: case sectionUsers:
return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit") return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit")
} }
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
@@ -175,10 +178,44 @@ func (m Model) confirmPrompt() string {
return "Delete schedule entry? [y/N]" return "Delete schedule entry? [y/N]"
case confirmDeleteUser: case confirmDeleteUser:
return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username) return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username)
case confirmReassignSchedule:
if p := m.pendingAssign; p != nil {
return fmt.Sprintf("%s assigned to %s. Reassign to %s? [y/N]",
dayCount(len(p.taken), len(p.dates)), joinNames(p.holders), p.username)
}
return "Reassign these days? [y/N]"
} }
return "Are you sure? [y/N]" return "Are you sure? [y/N]"
} }
// dayCount phrases how much of an assignment is being taken from somebody. A
// single day says so plainly; a partial week says which part, because "3 of 7"
// is the difference between taking a shift and taking somebody's whole week.
func dayCount(taken, total int) string {
switch {
case total == 1:
return "This day is"
case taken == total:
return fmt.Sprintf("All %d days are", total)
default:
return fmt.Sprintf("%d of %d days are", taken, total)
}
}
// joinNames renders a list of people as prose.
func joinNames(names []string) string {
switch len(names) {
case 0:
return "somebody else"
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
}
// ── Dashboard ────────────────────────────────────────────────────────────── // ── Dashboard ──────────────────────────────────────────────────────────────
func (m Model) renderDashboard() string { func (m Model) renderDashboard() string {
@@ -187,6 +224,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 +398,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()
@@ -548,6 +589,15 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved by " + who return " Resolved by " + who
} }
return " Resolved (all alerts stopped firing)" return " Resolved (all alerts stopped firing)"
case api.EventNotified:
// An empty username here is not "the server acted": it means the page
// went to the shared fallback topic, so it belongs to nobody.
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
case api.EventNotifyFailed:
// The detail is "<kind>: <reason>", and the reason is the point — it is
// the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52)
default: default:
label := " " + e.Type label := " " + e.Type
if e.Detail != "" { if e.Detail != "" {
@@ -557,6 +607,25 @@ func eventLabel(e api.IncidentEvent) string {
} }
} }
// notifiedTarget names who a page reached. The server attaches no user when it
// published to the shared fallback topic, and saying so is the difference
// between "somebody was paged" and "the on-call rota was empty".
func notifiedTarget(username string) string {
if username == "" {
return "the fallback topic"
}
return username
}
// notifyKind renders the notification kind the server puts in Detail. It is an
// open set, so anything unrecognised is shown rather than dropped.
func notifyKind(detail string) string {
if detail == "" {
return ""
}
return " (" + detail + ")"
}
func buildAlertDetailContent(alert api.Alert, width int) string { func buildAlertDetailContent(alert api.Alert, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
@@ -731,6 +800,16 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n" emailLabel + m.userFormInputs[1].View() + "\n"
} }
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username))
hint := line(styleMuted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.")
label := styleSelected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
}
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 := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
@@ -806,12 +885,19 @@ func renderBarWidth(count, maxCount, maxWidth int) int {
return w return w
} }
// truncate shortens s to max terminal cells, marking the cut with an ellipsis.
//
// Counted in runes rather than bytes: these strings are laid out against
// fixed-width columns, and a byte cut through a multi-byte rune would both
// mis-measure the column and emit a broken character. Server-supplied text —
// labels, annotations, delivery errors — is not guaranteed to be ASCII.
func truncate(s string, max int) string { func truncate(s string, max int) string {
if max < 1 { if max < 1 {
return "" return ""
} }
if len(s) <= max { r := []rune(s)
if len(r) <= max {
return s return s
} }
return s[:max-1] + "…" return string(r[:max-1]) + "…"
} }
+61 -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"
) )
@@ -174,6 +175,18 @@ func TestEventLabel_KnownTypes(t *testing.T) {
{api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"}, {api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"},
// No user means the server closed it via the alert cascade. // No user means the server closed it via the alert cascade.
{api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"}, {api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered"},
"Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that
// the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"},
"Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) { t.Run(tt.event.Type, func(t *testing.T) {
@@ -182,6 +195,32 @@ func TestEventLabel_KnownTypes(t *testing.T) {
} }
} }
// The timeline is where a page that never landed becomes visible, so both
// outcomes have to survive into the rendered pane.
func TestIncidentDetail_RendersNotifications(t *testing.T) {
now := time.Now()
inc := api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
timeline := []api.IncidentEvent{
{Type: api.EventTriggered, CreatedAt: now},
{Type: api.EventNotified, Username: "niklas", Detail: "triggered", CreatedAt: now},
{Type: api.EventNotifyFailed, Username: "niklas",
Detail: "reminder: ntfy returned 502", CreatedAt: now},
}
got := buildIncidentDetailContent(inc, timeline, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
}
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) {
m := sized()
m.mode = modeUserNotifyEdit
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic")
// The footer has to repeat it: that is where the reader looks for what a key does.
mustContain(t, m.renderFooter(), "empty clears the topic")
}
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
now := time.Now() now := time.Now()
id := int64(7) id := int64(7)
@@ -239,7 +278,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 +293,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