4 Commits

Author SHA1 Message Date
Niklas Ye 27008086b0 Follow terdut-server into teams: switch team, per-team schedule
CI / test (push) Successful in 12s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 14s
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.

T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.

The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.

Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.

The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.

Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.

Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.

Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
2026-09-23 21:57:28 +02:00
Niklas Ye e0c5a5cba3 Set a user's web UI password from the Users section
CI / test (push) Successful in 21s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 24s
terdut-server v0.10.2 serves a web UI you sign in to with a password,
and every user starts without one. Until now the only way to give
somebody their first password was a curl call with an API key. p in
Users sets the selected user's password.

The form asks for the current password only in the one case the server
checks it: you are changing your own password and already have one. The
client has no other way to know who its key belongs to, so opening the
form calls GET /api/me first and shows the fields once that answers.
Setting someone else's password sends no current_password at all,
rather than an empty one.

Length (at least 10) and the repeated entry are checked before anything
is sent, mirroring the server's rule so a typo costs no round trip. The
server stays authoritative: a wrong current password comes back as its
own 403 message on the dashboard. The status line says the user's other
web sessions were signed out, because the server does that on every
password change. API keys are not affected.

Older servers have no /api/me. The client now returns a typed
StatusError carrying the status code, so a 404 there reads as "needs
terdut-server v0.10.2 or later" rather than a bare "server returned
404". Its Error() text is unchanged, so every existing message reads as
before.

Requires terdut-server v0.10.2 only for this form. Everything else works
against the same servers as before.
2026-09-19 21:09:56 +02:00
Niklas Ye 0006424eaf Act on the selected row, not the one the table moved to
In Users, pressing k on a user opened API keys for the user above it.
The dashboard hands every key to the section's table before the
section's own handler reads the cursor, and bubbles' table claims
several letters for navigation: k is up, d half a page down, f a page
down. A letter that is also an action therefore moved the cursor first,
and the action landed on the row it had moved to. With your own user
first in the list, k looked like it only ever showed your own keys.

The same collision hit two destructive keys:

- d in Users asked to delete a user half a page below the selected one.
  The confirmation names the user, and that was the only thing standing
  between a keypress and deleting the wrong person.
- d in Schedule targeted a different day's assignment the same way.

f in Incidents and Alerts cycled the filter and paged the cursor down
too, which was harmless but wrong.

Each table now gives up exactly the letters its section acts on,
through tableKeyMap. The arrow keys and every other default binding are
untouched. The cost is that k no longer moves up in Users, where it
means API keys, as the README has always said. The up arrow still
works, and the README now says to use it there.

users_test.go reproduces all four. With tableKeyMap reverted to the
defaults, each of them fails exactly as reported.
2026-09-19 21:09:06 +02:00
niklas 9a510ecc77 Merge pull request 'Colour themes, defaulting to gruvbox dark' (#1) from color-themes into main
CI / test (push) Successful in 2s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Reviewed-on: #1
2026-08-20 09:11:13 +00:00
14 changed files with 1660 additions and 194 deletions
+13 -3
View File
@@ -1,6 +1,6 @@
# terdut-tui
TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.4.0+**.
TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.20.0+** (team-scoped API).
## Domain model
@@ -11,6 +11,14 @@ The server splits alerts from incidents, and this client mirrors it:
assignee, snooze, notes and an append-only timeline. Many alerts to one incident,
correlated by Alertmanager's `groupKey`.
The server is multi-team: incidents, alerts and the schedule belong to a team, and
the caller only sees their own teams. `Model.activeTeamID` (0 = all) narrows the
incident and alert lists via `team_id`; the schedule is per team and uses
`Model.scheduleTeam()`. Users have `is_admin`, and the TUI mirrors the server's
permission rules up front (`canEditSchedule`, `canManageUser`, `isAdmin`) so a 403 is
explained before the round trip, not after. The server has no version endpoint;
an old one is recognised by `GET /api/teams` answering 404 (`errServerTooOld`).
All user actions target incidents. Two server behaviours the UI has to respect:
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
non-destructive "not now" alternative.
@@ -25,7 +33,7 @@ non-destructive "not now" alternative.
## Project layout
```
main.go CLI entry point: flags, config load, health check, start TUI
main.go CLI entry point: flags, config load, start TUI
internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
@@ -35,7 +43,7 @@ internal/tui/ Bubbletea UI
view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern)
styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via GitHub Releases
internal/updater/updater.go Self-update via Gitea releases
```
## Architecture rules
@@ -57,6 +65,7 @@ server_url: https://terdut.example.com
api_key: <64-char hex key>
refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all
```
Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
@@ -95,6 +104,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 4 | On-call schedule calendar view |
| 5 | User management and API key lifecycle |
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
| 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter)
+34 -9
View File
@@ -10,12 +10,33 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **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
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries
- **Teams** — switch between your teams, or see all of them at once
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **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;
> use terdut-tui v0.3.x with those.
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> v0.12 and this client follows it; earlier servers answer 404 for `/api/teams`
> and the TUI says so on start. Use terdut-tui v0.9.x with servers before v0.12.
> Escalation ladders, invites, integrations and the admin settings stay in the
> server's web UI.
## Teams
Everything the server returns is scoped to the teams your key's user belongs
to. The header shows which are on screen, and `T` steps through *all* → each of
your teams in turn. With several teams showing, incident and alert rows carry a
Team column.
The schedule is one team's rota, so the Schedule section shows the active team,
or with *all* showing the first team you own. Only a team's owners, and
administrators, can change its rota; anyone else gets the reason in the status
bar instead of a picker. The picker offers only that team's members, because the
server refuses anybody else. Stats are not team-scoped by the server and always
cover all your teams.
Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own.
## Alerts and incidents
@@ -49,9 +70,6 @@ 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
@@ -70,6 +88,7 @@ server_url: https://terdut.example.com
api_key: <your-api-key>
refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
team: Ops # optional, a team name or id to start on; default is all
```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key.
@@ -133,6 +152,7 @@ Global:
| `esc` | Go back |
| `r` | Refresh |
| `f` | Cycle filter |
| `T` | Switch team: all → each of your teams (when you have more than one) |
| `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
@@ -181,9 +201,8 @@ Schedule section:
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`.
rather than seven deletions. The header line names the team whose rota this is,
and "On-call today" lists everyone on call across your teams.
Users section:
@@ -193,3 +212,9 @@ Users section:
| `t` | Edit the user's ntfy topic — submit empty to clear it |
| `d` | Delete a user |
| `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own |
In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather
than `k`. The Flags column marks administrators and disabled accounts. `n` and `d`
are for administrators; `t`, `k` and `p` work on your own row, or on anyone's if you
are one.
+98 -41
View File
@@ -37,6 +37,20 @@ func (c *Client) newRequest(method, path string) (*http.Request, error) {
return req, nil
}
// StatusError is a response the server answered with a 4xx or 5xx. Message is
// the server's own {"error": ...} text, empty when the body carried none.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -49,10 +63,7 @@ func (c *Client) do(req *http.Request, out any) error {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
if out != nil {
@@ -61,14 +72,18 @@ func (c *Client) do(req *http.Request, out any) error {
return nil
}
// ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all.
// ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
// the caller belongs to. status may be "firing", "resolved", or "" for all.
// Set archived=true to fetch only archived alerts; false returns only non-archived.
//
// Alerts are read-only on the server — there is nothing to acknowledge or
// archive here. This is the raw feed, useful for checking what Alertmanager is
// actually sending; the work queue is ListIncidents.
func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) {
func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" {
q.Set("status", status)
}
@@ -127,12 +142,16 @@ func (c *Client) GetAlert(id int64) (*Alert, error) {
// ── Incidents ──────────────────────────────────────────────────────────────
// ListIncidents fetches the work queue. status may be "triggered",
// ListIncidents fetches the work queue. teamID limits it to one team; 0 means
// every team the caller belongs to. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents
// only. archived and snoozed each switch the list to that set rather than
// adding to it, matching the server's filters.
func (c *Client) ListIncidents(status string, archived, snoozed bool, limit int) ([]Incident, error) {
func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" {
q.Set("status", status)
}
@@ -318,8 +337,37 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
return result, c.do(req, &result)
}
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
// ── Teams ──────────────────────────────────────────────────────────────────
// ListTeams returns the teams the caller belongs to, with the caller's role in
// each. Everything else the server returns is scoped to these. A server that
// predates teams (v0.12) answers 404, which is how the TUI spots one.
func (c *Client) ListTeams() ([]Team, error) {
req, err := c.newRequest(http.MethodGet, "/api/teams")
if err != nil {
return nil, err
}
var teams []Team
return teams, c.do(req, &teams)
}
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
// its own members, so this is the assignee list for one.
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
if err != nil {
return nil, err
}
var members []TeamMember
return members, c.do(req, &members)
}
// ── Schedule ───────────────────────────────────────────────────────────────
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
q := url.Values{"from": {from}, "to": {to}}
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
if err != nil {
return nil, err
}
@@ -327,49 +375,30 @@ func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
return entries, c.do(req, &entries)
}
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled.
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
// GetCurrentOnCall returns today's on-call entries, one per team that has
// somebody scheduled. It is empty, not an error, when nobody is.
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var entry ScheduleEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, err
}
return &entry, nil
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
// AssignSchedule puts one user on call for the given dates.
// AssignSchedule puts one team member on call for the given dates. Only a team
// owner or an administrator may.
//
// 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) {
func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct {
UserID int64 `json:"user_id"`
Dates []string `json:"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, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
if err != nil {
return nil, err
}
@@ -377,14 +406,16 @@ func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]S
return entries, c.do(req, &entries)
}
func (c *Client) DeleteScheduleEntry(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id))
func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
if err != nil {
return err
}
return c.do(req, nil)
}
// ── Users ──────────────────────────────────────────────────────────────────
func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil {
@@ -452,7 +483,33 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error {
return c.do(req, nil)
}
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
// Me returns the user the API key belongs to, and whether they have a web UI
// password.
func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil {
return nil, err
}
var me Me
return &me, c.do(req, &me)
}
// SetPassword sets a user's web UI password. current is only checked by the
// server when a user changes their own existing password; pass "" otherwise.
func (c *Client) SetPassword(userID int64, password, current string) error {
body := struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}{Password: password, CurrentPassword: current}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
// check, so it says the process is up, not that the API key works.
func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
if err != nil {
+149 -17
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -39,7 +40,7 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
func TestClient_SendsBearerToken(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents("", false, false, 0); err != nil {
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err)
}
if got.auth != "Bearer test-key" {
@@ -107,23 +108,26 @@ func TestClient_IncidentEndpoints(t *testing.T) {
func TestListIncidents_Filters(t *testing.T) {
tests := []struct {
name string
teamID int64
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"},
{"default is the open queue", 0, "", false, false, 0, ""},
{"status", 0, "triggered", false, false, 0, "status=triggered"},
{"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", 0, "", false, true, 0, "snoozed=true"},
{"limit", 0, "", false, false, 500, "limit=500"},
{"one team", 4, "", false, false, 0, "team_id=4"},
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
}
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 {
if _, err := c.ListIncidents(tt.teamID, tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
t.Fatalf("list: %v", err)
}
if got.query != tt.want {
@@ -170,9 +174,12 @@ func TestClient_RequestBodies(t *testing.T) {
// 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 {
if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err)
}
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
t.Errorf("unexpected body %q", got.body)
}
@@ -180,7 +187,7 @@ func TestClient_RequestBodies(t *testing.T) {
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 {
if _, err := c.AssignSchedule(9, 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}` {
@@ -246,15 +253,91 @@ func TestClient_ErrorWithoutBody(t *testing.T) {
}
}
// 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()
// Nobody on call is a normal state, not a failure: the server answers with an
// empty list, one entry per team that has somebody scheduled.
func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
entries, err := c.GetCurrentOnCall()
if err != nil {
t.Fatalf("expected no error, got %v", err)
t.Fatalf("on call: %v", err)
}
if entry != nil {
t.Errorf("expected nil entry, got %+v", entry)
if got.path != "/api/schedule/current" {
t.Errorf("unexpected path %q", got.path)
}
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
t.Errorf("unexpected entries %+v", entries)
}
c, _ = stub(t, http.StatusOK, `[]`)
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
}
}
// Schedules belong to a team, so every call for one has to say which.
func TestSchedule_IsPerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
t.Fatalf("get schedule: %v", err)
}
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
c, got = stub(t, http.StatusNoContent, ``)
if err := c.DeleteScheduleEntry(7, 12); err != nil {
t.Fatalf("delete: %v", err)
}
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
t.Errorf("unexpected request %s %s", got.method, got.path)
}
}
func TestTeams(t *testing.T) {
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
teams, err := c.ListTeams()
if err != nil {
t.Fatalf("list teams: %v", err)
}
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
t.Errorf("unexpected %s %+v", got.path, teams)
}
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
members, err := c.ListTeamMembers(3)
if err != nil {
t.Fatalf("list members: %v", err)
}
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
t.Errorf("unexpected %s %+v", got.path, members)
}
}
// A server that predates teams has no /api/teams, and the TUI recognises one by
// that 404, so it must come back as a StatusError carrying the code.
func TestListTeams_OldServerIs404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.ListTeams()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("expected a 404 StatusError, got %v", err)
}
}
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
var u User
if err := json.Unmarshal([]byte(
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
t.Fatalf("decode: %v", err)
}
if !u.IsAdmin || !u.IsDisabled() {
t.Errorf("expected an admin who is disabled, got %+v", u)
}
var other User
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
t.Errorf("a user with no disabled_at must not be disabled")
}
}
@@ -339,7 +422,7 @@ func TestAlert_DecodesIncidentLink(t *testing.T) {
func TestListAlerts_ArchivedFilter(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListAlerts("", true, 50); err != nil {
if _, err := c.ListAlerts(0, "", true, 50); err != nil {
t.Fatalf("list alerts: %v", err)
}
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
@@ -363,3 +446,52 @@ func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
}
}
func TestClient_Me(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`)
me, err := c.Me()
if err != nil {
t.Fatalf("me: %v", err)
}
if got.method != http.MethodGet || got.path != "/api/me" {
t.Errorf("expected GET /api/me, got %s %s", got.method, got.path)
}
if me.User.ID != 3 || !me.HasPassword {
t.Errorf("unexpected decode %+v", me)
}
}
func TestClient_SetPassword(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.SetPassword(2, "a brand new secret", ""); err != nil {
t.Fatalf("set password: %v", err)
}
if got.method != http.MethodPut || got.path != "/api/users/2/password" {
t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path)
}
// Setting someone else's password carries no current_password at all,
// rather than an empty one.
if got.body != `{"password":"a brand new secret"}` {
t.Errorf("unexpected body %s", got.body)
}
c, got = stub(t, http.StatusNoContent, ``)
c.SetPassword(1, "a brand new secret", "the old one")
if !strings.Contains(got.body, `"current_password":"the old one"`) {
t.Errorf("current password missing from %s", got.body)
}
}
// Older servers have no /api/me; the caller tells that apart by the status
// code, so the typed error has to carry it.
func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `404 page not found`)
_, err := c.Me()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("expected a 404 StatusError, got %v", err)
}
if err.Error() != "server returned 404" {
t.Errorf("message changed: %q", err.Error())
}
}
+62 -3
View File
@@ -7,6 +7,8 @@ import "time"
// alert belongs to.
type Alert struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
Fingerprint string `json:"fingerprint"`
Name string `json:"name"`
Status string `json:"status"`
@@ -25,7 +27,8 @@ type Alert struct {
// ResolutionSource records why a resolved alert left the firing state:
// "alertmanager" for a real resolved webhook, "expiry" when the server
// inferred it after the alert stopped being refreshed.
// inferred it after the alert stopped being refreshed, "deadman" for a
// dead man's switch that came back. Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"`
}
@@ -41,6 +44,8 @@ const (
// groupKey Alertmanager computed from the operator's group_by configuration.
type Incident struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
GroupKey string `json:"group_key"`
Title string `json:"title"`
GroupLabels map[string]string `json:"group_labels"`
@@ -63,10 +68,17 @@ type Incident struct {
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolutionSource is "alerts" when every alert stopped firing, or "manual"
// when a person closed it. Treat the value set as open.
// ResolutionSource is "alerts" when every alert stopped firing, "manual"
// when a person closed it, or "recovered" when a dead man's switch came back.
// Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"`
// EscalationLevel is how far up the team's escalation ladder the incident has
// climbed (0 = not escalated). EscalationDueAt is when the next step fires,
// and is nil once the ladder is exhausted or the incident is acknowledged.
EscalationLevel int `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Alerts is populated by GET /api/incidents/{id} only.
@@ -96,6 +108,9 @@ const (
EventResolved = "resolved"
EventNote = "note"
// Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent"
// 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
@@ -154,6 +169,8 @@ type DayStat struct {
type ScheduleEntry struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD
@@ -166,6 +183,14 @@ type User struct {
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
// IsAdmin marks a system administrator: the only kind of user who can create
// or delete users and act on other people's passwords and keys.
IsAdmin bool `json:"is_admin"`
// DisabledAt is set when an administrator has disabled the account. A
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// 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
@@ -182,6 +207,40 @@ func (u User) Topic() string {
return *u.NtfyTopic
}
// IsDisabled reports whether the account has been disabled.
func (u User) IsDisabled() bool { return u.DisabledAt != nil }
// Me is GET /api/me: the caller, and whether they can sign in to the web UI.
type Me struct {
User User `json:"user"`
HasPassword bool `json:"has_password"`
}
// Team roles.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// Team is a group that owns integrations, incidents, a schedule and an
// escalation ladder. Role is the caller's role in it, and is only present on
// the caller's own team lists (GET /api/teams).
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Role string `json:"role,omitempty"`
}
// TeamMember is one person's membership of a team.
type TeamMember struct {
TeamID int64 `json:"team_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
+7 -1
View File
@@ -16,6 +16,10 @@ type Config struct {
APIKey string
RefreshInterval time.Duration
Theme string
// Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to.
Team string
}
type rawConfig struct {
@@ -23,6 +27,7 @@ type rawConfig struct {
APIKey string `yaml:"api_key"`
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"`
}
func Load() (*Config, error) {
@@ -35,7 +40,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional", path)
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path)
}
return nil, fmt.Errorf("cannot read config file: %w", err)
}
@@ -62,5 +67,6 @@ func Load() (*Config, error) {
APIKey: raw.APIKey,
RefreshInterval: interval,
Theme: raw.Theme,
Team: raw.Team,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, "terdut-tui"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "terdut-tui", "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoad_TeamIsOptional(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "" {
t.Errorf("expected no default team, got %q", cfg.Team)
}
writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\nteam: Ops\n")
cfg, err = Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "Ops" {
t.Errorf("expected team Ops, got %q", cfg.Team)
}
}
+385 -67
View File
@@ -1,12 +1,18 @@
package tui
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
@@ -45,8 +51,21 @@ const (
modeAPIKeyCreate
modeAPIKeyReveal
modeAPIKeyRevokeByID
modePasswordSet
)
// Fields of the set-password form, in tab order.
const (
pwCurrent = iota
pwNew
pwRepeat
pwFieldCount
)
// minPasswordLen mirrors the server's rule, so a short password is refused
// here rather than after a round trip.
const minPasswordLen = 10
type confirmTarget int
const (
@@ -91,7 +110,10 @@ func filterLabel(filter string) string {
// ── Messages ───────────────────────────────────────────────────────────────
// dashboard
type connectedMsg struct{}
type connectedMsg struct {
teams []api.Team
me api.Me
}
type connectErrMsg struct{ err error }
type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
@@ -126,7 +148,11 @@ type detailStatsErrMsg struct{ err error }
// schedule
type scheduleFetchedMsg struct {
entries []api.ScheduleEntry
current *api.ScheduleEntry
current []api.ScheduleEntry
}
type pickerReadyMsg struct {
users []api.User
members map[int64]bool
}
type scheduleFetchErrMsg struct{ err error }
type scheduleActionErrMsg struct{ err error }
@@ -136,6 +162,8 @@ type usersFetchedMsg struct{ users []api.User }
type apiKeyCreatedMsg struct{ key api.APIKey }
type apiKeyRevokedMsg struct{}
type userActionErrMsg struct{ err error }
type meFetchedMsg struct{ me api.Me }
type passwordSetMsg struct{ username string }
// ── Model ──────────────────────────────────────────────────────────────────
@@ -166,6 +194,15 @@ type Model struct {
width int
height int
// Teams. The server scopes everything to the caller's teams; activeTeamID
// narrows the incident and alert lists to one of them, 0 meaning all. The
// schedule is per team and always needs a concrete one, see scheduleTeam.
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
// Connection & dashboard
connected bool
loading bool
@@ -223,7 +260,7 @@ type Model struct {
scheduleWindow time.Time
scheduleEntries []api.ScheduleEntry
scheduleDays []scheduleDay
currentOnCall *api.ScheduleEntry
currentOnCall []api.ScheduleEntry
scheduleLoading bool
scheduleTable table.Model
@@ -233,6 +270,9 @@ type Model struct {
userPickerTable table.Model
pickerTarget pickerTarget
pickerAssignWeek bool
// pickerMembers is who belongs to the schedule's team, so the schedule picker
// offers only people the server will accept. Nil until fetched.
pickerMembers map[int64]bool
// User management section
userManageTable table.Model
@@ -244,6 +284,15 @@ type Model struct {
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
// Set-password form. The current-password field is shown only when the
// target is the key's own user and already has a password, which is the
// one case the server asks for it; pwLoading covers the /api/me lookup
// that decides it.
pwInputs [pwFieldCount]textinput.Model
pwFocus int
pwNeedCurrent bool
pwLoading bool
help help.Model
keys keyMap
styles Styles
@@ -253,22 +302,26 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
st := newStyles(th)
ts := st.Table()
incidentT := table.New(table.WithFocused(true))
// Each table sees a key before the section's own handler does, so any
// key a section uses as an action must be taken out of that table's
// navigation bindings, or the cursor moves first and the action lands on
// a different row. See tableKeyMap.
incidentT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
incidentT.SetStyles(ts)
alertT := table.New(table.WithFocused(true))
alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true))
archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true))
schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
schedT.SetStyles(ts)
pickerT := table.New(table.WithFocused(true))
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true))
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts)
// Sized by the first tea.WindowSizeMsg; built here so it carries the default
@@ -303,11 +356,23 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20
var pwIn [pwFieldCount]textinput.Model
for i, placeholder := range [pwFieldCount]string{"current password", "new password (min. 10 characters)", "repeat new password"} {
pwIn[i] = textinput.New()
pwIn[i].Placeholder = placeholder
pwIn[i].EchoMode = textinput.EchoPassword
pwIn[i].EchoCharacter = '•'
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
}
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
for i := range pwIn {
pwIn[i] = st.Input(pwIn[i])
}
helpModel := help.New()
helpModel.Styles = st.Help()
@@ -344,18 +409,52 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
pwInputs: pwIn,
help: helpModel,
keys: keys,
styles: st,
}
}
// WithDefaultTeam names the team to start on, by name or id. It is resolved
// against the caller's teams once connected; an unknown one is reported and the
// TUI starts on all teams.
func (m Model) WithDefaultTeam(team string) Model {
m.defaultTeam = strings.TrimSpace(team)
return m
}
func (m Model) Init() tea.Cmd {
return connectCmd(m.client)
}
// ── Table rebuilders ───────────────────────────────────────────────────────
// tableKeyMap is the bubbles table keymap without the given keys.
//
// The table's defaults claim several letters -- k up, d half a page down, f a
// page down -- and the dashboard hands every key to the table before the
// section's own handler reads the cursor. A letter that is both, like k for
// API keys in Users, therefore moved the cursor and then acted on the row it
// had moved to. Each table gives up the letters its section acts on; the
// arrow keys and the rest of the defaults are untouched.
func tableKeyMap(reserved ...string) table.KeyMap {
km := table.DefaultKeyMap()
for _, b := range []*key.Binding{
&km.LineUp, &km.LineDown, &km.PageUp, &km.PageDown,
&km.HalfPageUp, &km.HalfPageDown, &km.GotoTop, &km.GotoBottom,
} {
var keep []string
for _, k := range b.Keys() {
if !slices.Contains(reserved, k) {
keep = append(keep, k)
}
}
b.SetKeys(keep...)
}
return km
}
// setRows replaces a table's rows and keeps its cursor in a state the rest of
// this package can rely on: valid whenever the table has any rows at all.
//
@@ -374,21 +473,34 @@ func setRows(t *table.Model, rows []table.Row) {
}
}
// setTable replaces a table's columns and rows together, for tables whose column
// count can change (the Team column comes and goes). bubbles re-renders the
// existing rows as soon as SetColumns is called, and a row with a different
// number of cells than the new columns indexes past the end and panics, so the
// old rows have to go first. The cursor is put back afterwards, since a refresh
// must not send it to the top.
func setTable(t *table.Model, cols []table.Column, rows []table.Row) {
cursor := t.Cursor()
t.SetRows(nil)
t.SetColumns(cols)
setRows(t, rows)
if cursor > 0 && cursor < len(rows) {
t.SetCursor(cursor)
}
}
func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width))
setRows(&m.incidentTable, incidentRows(m.incidents))
setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
m.incidentTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width))
setRows(&m.alertTable, alertRows(m.alerts))
setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
m.alertTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(incidentColumns(m.width))
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
m.archivedTable.SetHeight(tableHeight(m.height, 8))
}
@@ -400,8 +512,9 @@ func (m *Model) rebuildScheduleTable() {
func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetColumns(userPickerColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
pickable := m.pickerUsers()
rows := make([]table.Row, len(pickable))
for i, u := range pickable {
rows[i] = table.Row{u.Username, u.Email}
}
setRows(&m.userPickerTable, rows)
@@ -416,7 +529,7 @@ func (m *Model) rebuildUserManageTable() {
if topic == "" {
topic = "—"
}
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
}
setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10))
@@ -466,6 +579,106 @@ func (m Model) detailViewportHeight() int {
return h
}
// showTeamColumn is whether list rows need saying which team they belong to:
// only when they can come from more than one.
func (m Model) showTeamColumn() bool {
return m.activeTeamID == 0 && len(m.teams) > 1
}
// activeTeam returns the team the lists are narrowed to.
func (m Model) activeTeam() (api.Team, bool) {
return m.teamByID(m.activeTeamID)
}
func (m Model) teamByID(id int64) (api.Team, bool) {
for _, t := range m.teams {
if t.ID == id {
return t, true
}
}
return api.Team{}, false
}
// scheduleTeam is the team whose schedule the Schedule section shows. That is
// the active team; with all teams showing it is the first one the caller owns,
// else their first, because a rota belongs to one team and there is no
// meaningful union to display.
func (m Model) scheduleTeam() (api.Team, bool) {
if t, ok := m.activeTeam(); ok {
return t, true
}
for _, t := range m.teams {
if t.Role == api.RoleOwner {
return t, true
}
}
if len(m.teams) > 0 {
return m.teams[0], true
}
return api.Team{}, false
}
// canEditSchedule mirrors the server: writes need a team owner or an
// administrator. Saying so up front beats a 403 after picking a user.
func (m Model) canEditSchedule(t api.Team) bool {
return m.isAdmin || t.Role == api.RoleOwner
}
// canManageUser mirrors the server's self-or-admin rule for a user's password,
// ntfy topic and API keys.
func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID
}
// resolveTeam finds a team by id or, failing that, by name.
func resolveTeam(teams []api.Team, want string) (api.Team, bool) {
if id, err := strconv.ParseInt(want, 10, 64); err == nil {
for _, t := range teams {
if t.ID == id {
return t, true
}
}
}
for _, t := range teams {
if strings.EqualFold(t.Name, want) {
return t, true
}
}
return api.Team{}, false
}
// pickerUsers is who the user picker offers. Disabled accounts are never worth
// assigning to. For the schedule it is also limited to the team's members: the
// server answers 404 for anyone else, and shows nothing until they are known.
func (m Model) pickerUsers() []api.User {
out := make([]api.User, 0, len(m.users))
for _, u := range m.users {
if u.IsDisabled() {
continue
}
if m.pickerTarget == pickerSchedule && !m.pickerMembers[u.ID] {
continue
}
out = append(out, u)
}
return out
}
// userFlags is the Users table's marker column.
func userFlags(u api.User) string {
var flags []string
if u.IsAdmin {
flags = append(flags, "admin")
}
if u.IsDisabled() {
flags = append(flags, "disabled")
}
if len(flags) == 0 {
return "—"
}
return strings.Join(flags, ",")
}
// noteEvents filters a timeline down to the deletable entries, which is what
// the [ and ] cursor walks.
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
@@ -480,44 +693,67 @@ func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
// ── Column definitions ─────────────────────────────────────────────────────
func incidentColumns(width int) []table.Column {
// teamW is the width of the Team column shown when rows can span teams.
const teamW = 14
func incidentColumns(width int, showTeam bool) []table.Column {
const sevW, statusW, timeW = 9, 15, 12
titleW := width/2 - 10
if showTeam {
titleW -= teamW + 2
}
if titleW < 20 {
titleW = 20
}
// 10 = bubbles' Padding(0, 1) on each of the five cells.
assigneeW := width - sevW - titleW - statusW - timeW - 10
if showTeam {
assigneeW -= teamW + 2
}
if assigneeW < 8 {
assigneeW = 8
}
return []table.Column{
cols := []table.Column{
{Title: "Sev", Width: sevW},
{Title: "Incident", Width: titleW},
{Title: "Status", Width: statusW},
{Title: "Assignee", Width: assigneeW},
{Title: "Triggered", Width: timeW},
}
if showTeam {
cols = append(cols, table.Column{Title: "Team", Width: teamW})
}
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Assignee", Width: assigneeW},
table.Column{Title: "Triggered", Width: timeW},
)
}
func alertColumns(width int) []table.Column {
func alertColumns(width int, showTeam bool) []table.Column {
const statusW, timeW = 10, 12
nameW := width/2 - 14
if showTeam {
nameW -= teamW + 2
}
if nameW < 20 {
nameW = 20
}
// 10 = bubbles' Padding(0, 1) on each of the five cells.
incW := width - nameW - statusW - 2*timeW - 10
if showTeam {
incW -= teamW + 2
}
if incW < 8 {
incW = 8
}
return []table.Column{
{Title: "Name", Width: nameW},
{Title: "Status", Width: statusW},
{Title: "Started", Width: timeW},
{Title: "Last Seen", Width: timeW},
{Title: "Incident", Width: incW},
cols := []table.Column{{Title: "Name", Width: nameW}}
if showTeam {
cols = append(cols, table.Column{Title: "Team", Width: teamW})
}
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Started", Width: timeW},
table.Column{Title: "Last Seen", Width: timeW},
table.Column{Title: "Incident", Width: incW},
)
}
func scheduleColumns(width int) []table.Column {
@@ -548,8 +784,9 @@ func userManageColumns(width int) []table.Column {
createdW := 12
usernameW := 25
topicW := 22
// 8 = bubbles' Padding(0, 1) on each of the four cells.
emailW := width - usernameW - topicW - createdW - 8
flagsW := 14
// 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10
if emailW < 15 {
emailW = 15
}
@@ -557,13 +794,14 @@ func userManageColumns(width int) []table.Column {
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW},
}
}
// ── Row builders ───────────────────────────────────────────────────────────
func incidentRows(incidents []api.Incident) []table.Row {
func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
now := time.Now()
rows := make([]table.Row, len(incidents))
for i, inc := range incidents {
@@ -581,12 +819,16 @@ func incidentRows(incidents []api.Incident) []table.Row {
if assignee == "" {
assignee = "—"
}
if showTeam {
rows[i] = table.Row{severity, inc.Title, teamLabel(inc.TeamName), status, assignee, humanAgo(now, inc.TriggeredAt)}
continue
}
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
}
return rows
}
func alertRows(alerts []api.Alert) []table.Row {
func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
now := time.Now()
rows := make([]table.Row, len(alerts))
for i, a := range alerts {
@@ -594,11 +836,23 @@ func alertRows(alerts []api.Alert) []table.Row {
if a.IncidentID != nil {
incident = fmt.Sprintf("#%d", *a.IncidentID)
}
if showTeam {
rows[i] = table.Row{a.Name, teamLabel(a.TeamName), a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
continue
}
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
}
return rows
}
// teamLabel is a team name for a table cell, with a dash when the server sent none.
func teamLabel(name string) string {
if name == "" {
return "—"
}
return name
}
func scheduleRows(days []scheduleDay) []table.Row {
today := time.Now().UTC().Format("2006-01-02")
rows := make([]table.Row, len(days))
@@ -693,19 +947,38 @@ func humanSeconds(secs *float64) string {
// ── Commands ───────────────────────────────────────────────────────────────
// errServerTooOld is what connecting to a server without teams looks like: the
// server has no version endpoint, so its missing /api/teams is the tell.
var errServerTooOld = errors.New("this server predates teams -- terdut-tui needs terdut-server v0.20 or later")
// connectCmd checks the server is up, then loads the caller's teams and identity
// with their key. /healthz is unauthenticated, so this is also the first thing
// to notice a wrong key.
func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
if err := client.HealthCheck(); err != nil {
return connectErrMsg{err}
}
return connectedMsg{}
teams, err := client.ListTeams()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return connectErrMsg{errServerTooOld}
}
if err != nil {
return connectErrMsg{err}
}
me, err := client.Me()
if err != nil {
return connectErrMsg{err}
}
return connectedMsg{teams: teams, me: *me}
}
}
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg {
status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500)
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil {
return fetchDataErrMsg{err}
}
@@ -713,11 +986,11 @@ func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
}
}
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
func fetchArchivedIncidentsCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg {
// Archived incidents are all resolved, so the status filter has to be
// widened past the server's open-only default or nothing comes back.
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil {
return fetchDataErrMsg{err}
}
@@ -725,13 +998,13 @@ func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
}
}
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd {
func fetchAlertsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg {
status, archived := filter, false
if filter == "archived" {
status, archived = "", true
}
alerts, err := client.ListAlerts(status, archived, 500)
alerts, err := client.ListAlerts(teamID, status, archived, 500)
if err != nil {
return fetchDataErrMsg{err}
}
@@ -831,13 +1104,13 @@ func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
// archiveIncidentCmd archives from the list view, so it reloads the list rather
// than a detail pane.
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
func archiveIncidentCmd(client *api.Client, id, teamID int64, filter string) tea.Cmd {
return func() tea.Msg {
if _, err := client.ArchiveIncident(id); err != nil {
return actionErrMsg{err}
}
status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500)
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil {
return actionErrMsg{err}
}
@@ -845,12 +1118,12 @@ func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
}
}
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd {
func unarchiveIncidentCmd(client *api.Client, id, teamID int64) tea.Cmd {
return func() tea.Msg {
if err := client.UnarchiveIncident(id); err != nil {
return actionErrMsg{err}
}
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil {
return actionErrMsg{err}
}
@@ -886,51 +1159,73 @@ func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
}
}
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
// loadSchedule reads one team's window and everyone's on-call today, the way
// every schedule command ends so the view reflects what the server now holds.
func loadSchedule(client *api.Client, teamID int64, from, to time.Time) (scheduleFetchedMsg, error) {
entries, err := client.GetSchedule(teamID, from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleFetchedMsg{}, err
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleFetchedMsg{}, err
}
return scheduleFetchedMsg{entries: entries, current: current}, nil
}
func fetchScheduleCmd(client *api.Client, teamID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg {
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
msg, err := loadSchedule(client, teamID, from, to)
if err != nil {
return scheduleFetchErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleFetchErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
return msg
}
}
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
func assignScheduleCmd(client *api.Client, teamID, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates, replace); err != nil {
if _, err := client.AssignSchedule(teamID, userID, dates, replace); err != nil {
return scheduleActionErrMsg{err}
}
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
msg, err := loadSchedule(client, teamID, from, to)
if err != nil {
return scheduleActionErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleActionErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
return msg
}
}
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd {
func deleteScheduleEntryCmd(client *api.Client, teamID, entryID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteScheduleEntry(entryID); err != nil {
if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
return scheduleActionErrMsg{err}
}
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
msg, err := loadSchedule(client, teamID, from, to)
if err != nil {
return scheduleActionErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleActionErrMsg{err}
return msg
}
return scheduleFetchedMsg{entries: entries, current: current}
}
// fetchPickerCmd loads what the schedule's user picker offers: everyone, and who
// belongs to the team, since only members can be put on its rota.
func fetchPickerCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg {
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
members, err := client.ListTeamMembers(teamID)
if err != nil {
return userActionErrMsg{err}
}
ids := make(map[int64]bool, len(members))
for _, mem := range members {
ids[mem.UserID] = true
}
return pickerReadyMsg{users: users, members: ids}
}
}
@@ -996,6 +1291,29 @@ func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
}
}
func fetchMeCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
me, err := client.Me()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return userActionErrMsg{errors.New("this server has no passwords -- needs terdut-server v0.10.2 or later")}
}
if err != nil {
return userActionErrMsg{err}
}
return meFetchedMsg{me: *me}
}
}
func setPasswordCmd(client *api.Client, user api.User, password, current string) tea.Cmd {
return func() tea.Msg {
if err := client.SetPassword(user.ID, password, current); err != nil {
return userActionErrMsg{err}
}
return passwordSetMsg{username: user.Username}
}
}
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteAPIKey(userID, keyID); err != nil {
+43 -19
View File
@@ -143,7 +143,7 @@ func TestIncidentRows(t *testing.T) {
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
})
}, false)
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
@@ -159,12 +159,35 @@ func TestIncidentRows(t *testing.T) {
}
}
// Rows only carry a team cell when the columns have a Team header for it, or
// every cell after it would sit under the wrong heading.
func TestRows_TeamCellMatchesTeamColumn(t *testing.T) {
now := time.Now()
incRows := incidentRows([]api.Incident{{Title: "DiskFull", TeamName: "Ops", Status: api.StatusTriggered, TriggeredAt: now}}, true)
if got, want := len(incRows[0]), len(incidentColumns(120, true)); got != want {
t.Errorf("incident row has %d cells for %d columns", got, want)
}
if incRows[0][2] != "Ops" {
t.Errorf("expected the team after the title, got %v", incRows[0])
}
alRows := alertRows([]api.Alert{{Name: "DiskFull", StartsAt: now, ReceivedAt: now}}, true)
if got, want := len(alRows[0]), len(alertColumns(120, true)); got != want {
t.Errorf("alert row has %d cells for %d columns", got, want)
}
if alRows[0][1] != "—" {
t.Errorf("a missing team name should show an em dash, got %v", alRows[0])
}
if got, want := len(incidentRows([]api.Incident{{}}, false)[0]), len(incidentColumns(120, false)); got != want {
t.Errorf("incident row has %d cells for %d columns without teams", got, want)
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7)
rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"},
})
}, false)
if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4])
}
@@ -204,31 +227,31 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
// 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)),
// Five cells each, or six once a Team column is added. userManageColumns
// is five cells as well: username, email, topic, flags, created.
for name, tc := range map[string]struct {
cols []table.Column
cells int
}{
"incident": {incidentColumns(width, false), 5},
"incident with team": {incidentColumns(width, true), 6},
"alert": {alertColumns(width, false), 5},
"alert with team": {alertColumns(width, true), 6},
"user": {userManageColumns(width), 5},
} {
sum := 0
for _, w := range cols {
for _, w := range widths(tc.cols) {
sum += w
}
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells
padding := 2 * tc.cells // bubbles applies Padding(0, 1) to each cell
if len(tc.cols) != tc.cells {
t.Errorf("%s has %d columns, expected %d", name, len(tc.cols), tc.cells)
}
if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d",
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)
}
}
}
@@ -236,7 +259,7 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
// what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} {
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
cols := append(widths(incidentColumns(width, true)), widths(alertColumns(width, true))...)
cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols {
if w < 1 {
@@ -287,6 +310,7 @@ func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries
+294 -24
View File
@@ -1,6 +1,8 @@
package tui
import (
"fmt"
"slices"
"strconv"
"strings"
@@ -32,12 +34,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Dashboard messages ────────────────────────────────────────────────
case connectedMsg:
firstConnect := len(m.teams) == 0
m.connected = true
m.err = nil
m.teams = msg.teams
m.meID = msg.me.User.ID
m.isAdmin = msg.me.User.IsAdmin
var statusCmd tea.Cmd
if firstConnect && m.activeTeamID == 0 && m.defaultTeam != "" {
if t, ok := resolveTeam(m.teams, m.defaultTeam); ok {
m.activeTeamID = t.ID
} else {
m.statusMsg = fmt.Sprintf("team %q not found -- showing all teams", m.defaultTeam)
statusCmd = clearStatusCmd()
}
}
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
return m, tea.Batch(
tickCmd(m.refreshInterval),
fetchIncidentsCmd(m.client, m.incidentFilter),
fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
fetchStatsCmd(m.client),
statusCmd,
)
case connectErrMsg:
@@ -157,6 +176,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildUserManageTable()
return m, nil
case pickerReadyMsg:
if m.mode != modeUserPicker {
return m, nil // the picker was closed before the lookup came back
}
m.users = msg.users
m.pickerMembers = msg.members
m.usersLoading = false
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
return m, nil
case apiKeyCreatedMsg:
m.revealedAPIKey = msg.key
m.mode = modeAPIKeyReveal
@@ -167,8 +197,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = modeDashboard
return m, clearStatusCmd()
case meFetchedMsg:
if m.mode != modePasswordSet {
return m, nil // the form was closed before the lookup came back
}
m.pwLoading = false
m.pwNeedCurrent = msg.me.User.ID == m.selectedUser.ID && msg.me.HasPassword
m.pwFocus = pwNew
if m.pwNeedCurrent {
m.pwFocus = pwCurrent
}
m.pwInputs[m.pwFocus].Focus()
return m, nil
case passwordSetMsg:
m.statusMsg = "password set for " + msg.username + " -- their other web sessions were signed out"
return m, clearStatusCmd()
case userActionErrMsg:
m.usersLoading = false
m.pwLoading = false
m.blurPasswordForm()
m.statusMsg = "error: " + msg.err.Error()
m.mode = modeDashboard
return m, clearStatusCmd()
@@ -205,22 +254,32 @@ func (m Model) refreshActiveSection() tea.Cmd {
switch m.activeSection {
case sectionIncidents:
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
return tea.Batch(fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client))
case sectionAlerts:
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client))
return tea.Batch(fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter), fetchStatsCmd(m.client))
case sectionStats:
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
case sectionArchived:
return fetchArchivedIncidentsCmd(m.client)
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
case sectionSchedule:
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
return m.fetchScheduleWindowCmd()
case sectionUsers:
return fetchUsersCmd(m.client)
}
return nil
}
// fetchScheduleWindowCmd reloads the schedule window for the schedule's team, or
// does nothing when the caller belongs to none.
func (m Model) fetchScheduleWindowCmd() tea.Cmd {
team, ok := m.scheduleTeam()
if !ok {
return nil
}
return fetchScheduleCmd(m.client, team.ID, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
// routeKey passes the key to the active component then to our handler.
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode {
@@ -279,6 +338,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg)
case modePasswordSet:
var inputCmd tea.Cmd
if !m.pwLoading {
m.pwInputs[m.pwFocus], inputCmd = m.pwInputs[m.pwFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
default: // modeDashboard
if m.connected {
switch m.activeSection {
@@ -338,6 +405,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
return m.handlePasswordKey(msg)
case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal:
@@ -379,14 +448,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case sectionIncidents:
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
m.loading = true
return m, fetchIncidentsCmd(m.client, m.incidentFilter)
return m, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)
case sectionAlerts:
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
m.loading = true
return m, fetchAlertsCmd(m.client, m.alertFilter)
return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
}
return m, nil
case "T":
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
case "enter":
switch m.activeSection {
case sectionIncidents:
@@ -419,14 +494,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.statusMsg = "resolve the incident before archiving it"
return m, clearStatusCmd()
}
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case sectionArchived:
i := m.archivedTable.Cursor()
if i < 0 || i >= len(m.archivedIncidents) {
return m, nil
}
m.archivedLoading = true
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID)
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID, m.activeTeamID)
}
return m, nil
@@ -434,16 +509,18 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "left", "h":
if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
m.scheduleLoading = true
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
cmd := m.fetchScheduleWindowCmd()
m.scheduleLoading = cmd != nil
return m, cmd
}
return m, nil
case "right", "l":
if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
m.scheduleLoading = true
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
cmd := m.fetchScheduleWindowCmd()
m.scheduleLoading = cmd != nil
return m, cmd
}
return m, nil
@@ -451,6 +528,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected {
return m, nil
}
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = false
return m.openUserPicker(pickerSchedule)
@@ -458,6 +538,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected {
return m, nil
}
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = true
return m.openUserPicker(pickerSchedule)
@@ -487,6 +570,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if cursor < 0 || cursor >= len(m.users) {
return m, nil
}
if !m.isAdmin {
m.statusMsg = "only administrators can delete users"
return m, clearStatusCmd()
}
m.selectedUser = m.users[cursor]
m.confirmTarget = confirmDeleteUser
m.mode = modeConfirm
@@ -497,6 +584,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionUsers || !m.connected {
return m, nil
}
if !m.isAdmin {
m.statusMsg = "only administrators can create users"
return m, clearStatusCmd()
}
m.userFormInputs[0].Reset()
m.userFormInputs[1].Reset()
m.userFormFocus = 0
@@ -514,6 +605,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic")
return m, cmd
}
// 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())
@@ -531,20 +626,55 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("API keys")
return m, cmd
}
m.mode = modeAPIKeyMenu
return m, nil
case "p":
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]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("password")
return m, cmd
}
for i := range m.pwInputs {
m.pwInputs[i].Reset()
m.pwInputs[i].Blur()
}
m.pwNeedCurrent = false
m.pwLoading = true
m.mode = modePasswordSet
// Whether the form needs the current password depends on who the key
// belongs to, which the client does not otherwise know.
return m, fetchMeCmd(m.client)
}
return m, nil
}
// refuseUserAction explains a self-or-admin action declined up front, and is what
// the status bar clears afterwards. The server refuses these with a 403 anyway.
func (m *Model) refuseUserAction(what string) tea.Cmd {
m.statusMsg = "only administrators can change another user's " + what
return clearStatusCmd()
}
// loadSectionIfEmpty fetches a section's data the first time it is opened.
func (m *Model) loadSectionIfEmpty() tea.Cmd {
switch m.activeSection {
case sectionAlerts:
if len(m.alerts) == 0 {
m.loading = true
return fetchAlertsCmd(m.client, m.alertFilter)
return fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
}
case sectionStats:
if !m.statsLoaded {
@@ -554,12 +684,13 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
case sectionArchived:
if len(m.archivedIncidents) == 0 {
m.archivedLoading = true
return fetchArchivedIncidentsCmd(m.client)
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
}
case sectionSchedule:
if len(m.scheduleDays) == 0 {
m.scheduleLoading = true
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
cmd := m.fetchScheduleWindowCmd()
m.scheduleLoading = cmd != nil
return cmd
}
case sectionUsers:
if len(m.users) == 0 {
@@ -590,6 +721,18 @@ func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
m.pickerTarget = target
m.mode = modeUserPicker
if target == pickerSchedule {
// Only the team's own members can go on its rota, so who they are has to
// be known before anybody is offered.
team, ok := m.scheduleTeam()
if !ok {
m.mode = modeDashboard
return m, nil
}
m.pickerMembers = nil
m.usersLoading = true
return m, fetchPickerCmd(m.client, team.ID)
}
if len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
@@ -598,6 +741,63 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil
}
// switchTeam steps the active team through all teams, then each of the caller's
// teams in turn, and reloads what depends on it. Sections that are not on screen
// are emptied rather than fetched, so they load when next opened; the incident
// queue is the exception because it is what the caller returns to.
func (m Model) switchTeam() (Model, tea.Cmd) {
next := int64(0)
if m.activeTeamID == 0 {
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
}
}
}
m.activeTeamID = next
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
m.loading = true
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
m.rebuildScheduleTable()
label := "all teams"
if t, ok := m.activeTeam(); ok {
label = t.Name
}
m.statusMsg = "Team: " + label
cmds := []tea.Cmd{clearStatusCmd()}
if m.activeSection != sectionIncidents {
cmds = append(cmds, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter))
}
cmds = append(cmds, m.refreshActiveSection())
if m.activeSection == sectionSchedule {
m.scheduleLoading = true
}
return m, tea.Batch(cmds...)
}
// checkScheduleEditable says why a schedule change is refused, in the status
// bar, and reports whether it may go ahead. The server enforces the same rule.
func (m *Model) checkScheduleEditable() bool {
team, ok := m.scheduleTeam()
switch {
case !ok:
m.statusMsg = "you are not in any team"
case !m.canEditSchedule(team):
m.statusMsg = "only owners of " + team.Name + " can change its schedule"
default:
return true
}
return false
}
// nextFilter advances a filter cycle, wrapping at the end.
func nextFilter(cycle []string, current string) string {
for i, f := range cycle {
@@ -681,10 +881,10 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if inc.ArchivedAt != nil {
m.archivedLoading = true
m.mode = modeDashboard
return m, unarchiveIncidentCmd(m.client, inc.ID)
return m, unarchiveIncidentCmd(m.client, inc.ID, m.activeTeamID)
}
m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case "c":
m.mode = modeNote
@@ -844,7 +1044,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard
m.pendingDeleteEntry = nil
m.scheduleLoading = true
return m, deleteScheduleEntryCmd(m.client, entry.ID,
team, _ := m.scheduleTeam()
return m, deleteScheduleEntryCmd(m.client, team.ID, entry.ID,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
case confirmDeleteUser:
@@ -861,7 +1062,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
m.scheduleLoading = true
return m, assignScheduleCmd(m.client, p.userID, p.dates, true,
team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, p.userID, p.dates, true,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
@@ -882,10 +1084,11 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "enter":
cursor := m.userPickerTable.Cursor()
if cursor < 0 || cursor >= len(m.users) {
pickable := m.pickerUsers()
if cursor < 0 || cursor >= len(pickable) {
return m, nil
}
user := m.users[cursor]
user := pickable[cursor]
if m.pickerTarget == pickerIncidentAssignee {
m.mode = modeIncidentDetail
@@ -935,7 +1138,8 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// 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),
team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, user.ID, dates, m.scheduleOccupied(dates),
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
@@ -1127,3 +1331,69 @@ func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// ── Set password ──────────────────────────────────────────────────────────────
// pwFields is the set-password form's fields in tab order.
func (m Model) pwFields() []int {
if m.pwNeedCurrent {
return []int{pwCurrent, pwNew, pwRepeat}
}
return []int{pwNew, pwRepeat}
}
func (m *Model) blurPasswordForm() {
for i := range m.pwInputs {
m.pwInputs[i].Blur()
}
}
func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.blurPasswordForm()
m.pwLoading = false
m.mode = modeDashboard
return m, nil
}
if m.pwLoading {
return m, nil
}
switch msg.String() {
case "tab", "shift+tab":
fields := m.pwFields()
i := slices.Index(fields, m.pwFocus)
step := 1
if msg.String() == "shift+tab" {
step = len(fields) - 1
}
m.pwInputs[m.pwFocus].Blur()
m.pwFocus = fields[(i+step)%len(fields)]
m.pwInputs[m.pwFocus].Focus()
return m, nil
case "enter":
password := m.pwInputs[pwNew].Value()
switch {
case m.pwNeedCurrent && m.pwInputs[pwCurrent].Value() == "":
m.statusMsg = "enter your current password"
return m, clearStatusCmd()
case len(password) < minPasswordLen:
m.statusMsg = fmt.Sprintf("the password must be at least %d characters", minPasswordLen)
return m, clearStatusCmd()
case password != m.pwInputs[pwRepeat].Value():
m.statusMsg = "the two new passwords do not match"
return m, clearStatusCmd()
}
current := ""
if m.pwNeedCurrent {
current = m.pwInputs[pwCurrent].Value()
}
m.blurPasswordForm()
m.mode = modeDashboard
m.statusMsg = "Setting password…"
return m, setPasswordCmd(m.client, m.selectedUser, password, current)
}
return m, nil
}
+249
View File
@@ -35,6 +35,7 @@ func sized() Model {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.isAdmin = true // most handlers are being tested for what they do, not who may
return m
}
@@ -320,6 +321,8 @@ func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
{ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"},
}
m.pickerTarget = pickerSchedule
m.pickerMembers = map[int64]bool{1: true, 2: true}
m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week
@@ -693,6 +696,8 @@ func containsAll(s string, subs ...string) bool {
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.isAdmin = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
@@ -718,9 +723,253 @@ func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
}
// The picker waits for the team's members before offering anybody.
next, _ = m.Update(pickerReadyMsg{
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
members: map[int64]bool{1: true},
})
m = next.(Model)
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
// ── Teams ─────────────────────────────────────────────────────────────────
func twoTeams() []api.Team {
return []api.Team{
{ID: 1, Name: "Ops", Role: api.RoleOwner},
{ID: 2, Name: "Dev", Role: api.RoleMember},
}
}
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
next, _ := m.Update(connectedMsg{
teams: twoTeams(),
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
})
m = next.(Model)
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
}
if m.activeTeamID != 0 {
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
}
}
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
next, _ := m.Update(connectedMsg{teams: twoTeams()})
if got := next.(Model).activeTeamID; got != 2 {
t.Errorf("default team %q: expected team 2, got %d", want, got)
}
}
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
m = next.(Model)
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
m.activeTeamID, m.statusMsg)
}
if cmd == nil {
t.Error("expected the initial fetches to still be issued")
}
}
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
var seen []int64
for i := 0; i < 4; i++ {
var cmd tea.Cmd
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
}
}
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
m, _ = press(t, m, "T")
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
if !strings.Contains(m.statusMsg, "Ops") {
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
}
}
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 {
t.Errorf("without teams T has nothing to switch to")
}
}
func TestScheduleTeam(t *testing.T) {
m := sized()
if _, ok := m.scheduleTeam(); ok {
t.Error("no teams means no schedule")
}
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
}
m.activeTeamID = 2
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
t.Errorf("the active team wins, got %d", tm.ID)
}
}
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
m := scheduledWeek(nil)
m.isAdmin = false
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
m, cmd := press(t, m, "+")
if m.mode == modeUserPicker {
t.Fatal("a plain member must not get as far as the picker")
}
if !strings.Contains(m.statusMsg, "owners of Ops") {
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
}
if cmd == nil {
t.Error("the message should clear itself")
}
m.isAdmin = true // administrators may edit any team's rota
m.statusMsg = ""
m, _ = press(t, m, "+")
if m.mode != modeUserPicker {
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
}
}
// The picker fetches the team's members, and offers nobody until they arrive:
// the server answers 404 for anyone else.
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
m := scheduledWeek(nil)
m, cmd := press(t, m, "+")
if cmd == nil || !m.usersLoading {
t.Fatal("opening the picker should start loading the members")
}
if got := len(m.pickerUsers()); got != 0 {
t.Errorf("nobody should be offered before the members are known, got %d", got)
}
disabled := time.Now()
next, _ := m.Update(pickerReadyMsg{
users: []api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "outsider"},
{ID: 3, Username: "gone", DisabledAt: &disabled},
},
members: map[int64]bool{1: true, 3: true},
})
m = next.(Model)
got := m.pickerUsers()
if len(got) != 1 || got[0].Username != "niklas" {
t.Errorf("expected only the enabled member, got %+v", got)
}
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
t.Errorf("the table should match, got %d rows", len(rows))
}
}
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
m := threeUsers()
m.isAdmin = false
m.meID = 1
m, _ = press(t, m, "n")
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
}
m, _ = press(t, m, "d")
if m.mode != modeDashboard {
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
}
}
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
m := threeUsers() // cursor on erik, id 3
m.isAdmin = false
m.meID = 1
for _, key := range []string{"t", "k", "p"} {
next, _ := press(t, m, key)
if next.mode != modeDashboard {
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
}
if !strings.Contains(next.statusMsg, "another user's") {
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
}
}
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
}
}
func TestUserFlags(t *testing.T) {
now := time.Now()
cases := []struct {
u api.User
want string
}{
{api.User{}, "—"},
{api.User{IsAdmin: true}, "admin"},
{api.User{DisabledAt: &now}, "disabled"},
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
}
for _, c := range cases {
if got := userFlags(c.u); got != c.want {
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
}
}
}
// Rebuilding a list on every refresh must not send the cursor back to the top.
func TestRefresh_KeepsTheCursor(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
m.rebuildIncidentTable()
m.incidentTable.SetCursor(2)
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
if got := next.(Model).incidentTable.Cursor(); got != 2 {
t.Errorf("expected the cursor to stay on row 2, got %d", got)
}
}
// The Team column comes and goes as the team switches, and the table must
// survive its column count changing under rows that are already loaded.
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
m.rebuildIncidentTable()
m.teams = twoTeams()
m.rebuildIncidentTable() // five columns become six over a five-cell row
if got := len(m.incidentTable.Columns()); got != 6 {
t.Errorf("expected a Team column across two teams, got %d columns", got)
}
m.activeTeamID = 1
m.rebuildIncidentTable()
if got := len(m.incidentTable.Columns()); got != 5 {
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
}
}
+204
View File
@@ -0,0 +1,204 @@
package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
tea "github.com/charmbracelet/bubbletea"
)
// threeUsers is the Users section with the cursor on the last of three users.
func threeUsers() Model {
m := onUsers([]api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "anna"},
{ID: 3, Username: "erik"},
})
m.userManageTable.SetCursor(2)
return m
}
// The table used to see k before the section did, take it as "up", and the
// handler then opened API keys for the user above the one selected.
func TestUsers_APIKeysOpenForTheSelectedUser(t *testing.T) {
m, _ := press(t, threeUsers(), "k")
if m.mode != modeAPIKeyMenu {
t.Fatalf("expected the API key menu, got mode %v", m.mode)
}
if m.selectedUser.Username != "erik" {
t.Errorf("API keys opened for %s, want erik", m.selectedUser.Username)
}
}
// Same collision with d, which the table read as half a page down: the delete
// confirmation named a different user than the one under the cursor.
func TestUsers_DeleteTargetsTheSelectedUser(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.selectedUser.Username != "niklas" {
t.Errorf("delete asked about %q in mode %v, want niklas", m.selectedUser.Username, m.mode)
}
}
func TestSchedule_DeleteTargetsTheSelectedDay(t *testing.T) {
m := sized()
m.activeSection = sectionSchedule
m.scheduleEntries = []api.ScheduleEntry{
{ID: 10, UserID: 1, Username: "niklas", Date: m.scheduleWindow.Format("2006-01-02")},
{ID: 11, UserID: 2, Username: "anna", Date: m.scheduleWindow.AddDate(0, 0, 1).Format("2006-01-02")},
}
m.scheduleDays = buildScheduleDays(m.scheduleWindow, m.scheduleEntries)
m.rebuildScheduleTable()
m.scheduleTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.pendingDeleteEntry == nil || m.pendingDeleteEntry.ID != 10 {
t.Errorf("schedule delete targeted %+v, want entry 10", m.pendingDeleteEntry)
}
}
// f cycles the filter; it must not also page the cursor down.
func TestFilter_DoesNotMoveTheCursor(t *testing.T) {
m := sized()
m.incidents = make([]api.Incident, 40)
for i := range m.incidents {
m.incidents[i] = api.Incident{ID: int64(i + 1), Title: "x", Status: api.StatusTriggered, TriggeredAt: time.Now()}
}
m.rebuildIncidentTable()
m, _ = press(t, m, "f")
if c := m.incidentTable.Cursor(); c != 0 {
t.Errorf("f moved the cursor to %d", c)
}
}
// The arrow keys still move the users table, now that k is an action there.
func TestUsers_ArrowKeysStillNavigate(t *testing.T) {
m := threeUsers()
next, _ := m.Update(keyUp())
if c := next.(Model).userManageTable.Cursor(); c != 1 {
t.Errorf("up arrow left the cursor on %d, want 1", c)
}
}
func TestPassword_OpensForTheSelectedUserAndLooksUpWhoIAm(t *testing.T) {
m, cmd := press(t, threeUsers(), "p")
if m.mode != modePasswordSet || m.selectedUser.Username != "erik" {
t.Fatalf("expected the password form for erik, got mode %v for %q", m.mode, m.selectedUser.Username)
}
if !m.pwLoading || cmd == nil {
t.Error("the form should look up /api/me before it is usable")
}
if !strings.Contains(m.View(), "Checking who this key belongs to") {
t.Error("the form should say it is waiting")
}
}
func TestPassword_SomeoneElseNeedsNoCurrentPassword(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if m.pwNeedCurrent || m.pwFocus != pwNew {
t.Errorf("setting erik's password as niklas should not ask for a current one")
}
if strings.Contains(m.View(), "Current password") {
t.Error("the current-password field should be hidden")
}
}
func TestPassword_OwnExistingPasswordNeedsCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if !m.pwNeedCurrent || m.pwFocus != pwCurrent {
t.Fatal("changing your own existing password should ask for the current one first")
}
m = typeInto(t, m, "correct horse")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("a complete form should submit (mode %v)", m.mode)
}
}
func TestPassword_OwnFirstPasswordNeedsNoCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: false}})
if next.(Model).pwNeedCurrent {
t.Error("there is no current password to ask for yet")
}
}
func TestPassword_RejectsBeforeSending(t *testing.T) {
cases := []struct{ name, pw, repeat, want string }{
{"too short", "short", "short", "at least 10"},
{"mismatch", "a brand new secret", "a different secret", "do not match"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}}})
m = typeInto(t, next.(Model), tc.pw)
m, _ = press(t, m, "tab")
m = typeInto(t, m, tc.repeat)
m, cmd := press(t, m, "enter")
if m.mode != modePasswordSet {
t.Error("the form should stay open")
}
if !strings.Contains(m.statusMsg, tc.want) {
t.Errorf("status %q should mention %q", m.statusMsg, tc.want)
}
if cmd == nil {
return
}
// Only the clear-status timer may be scheduled, never a request.
if _, ok := cmd().(clearStatusMsg); !ok {
t.Error("nothing should be sent to the server")
}
})
}
}
func TestPassword_EscapeCancels(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
m, _ = press(t, m, "esc")
if m.mode != modeDashboard {
t.Errorf("esc should close the form, got mode %v", m.mode)
}
// A lookup arriving after the form closed must not reopen anything.
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 3}, HasPassword: true}})
if next.(Model).mode != modeDashboard {
t.Error("a late /api/me answer reopened the form")
}
}
func TestPassword_ErrorClosesTheFormWithAMessage(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(userActionErrMsg{errTest("server returned 403: current password is incorrect")})
m = next.(Model)
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "current password is incorrect") {
t.Errorf("expected the server's message on the dashboard, got %q in mode %v", m.statusMsg, m.mode)
}
}
func typeInto(t *testing.T, m Model, s string) Model {
t.Helper()
next, _ := m.Update(runes(s))
return next.(Model)
}
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
func keyUp() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} }
type errTest string
func (e errTest) Error() string { return string(e) }
+83 -10
View File
@@ -27,10 +27,21 @@ func (m Model) View() string {
func (m Model) renderHeader() string {
title := m.styles.Header.Render("terdut-tui")
if len(m.teams) > 0 {
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
}
right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width)
}
// activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok {
return t.Name
}
return "all"
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range sectionNames {
@@ -83,6 +94,8 @@ func (m Model) renderBody() string {
return m.renderAPIKeyReveal()
case modeAPIKeyRevokeByID:
return m.renderAPIKeyRevokeByID()
case modePasswordSet:
return m.renderPasswordSet()
default:
return m.renderDashboard()
}
@@ -144,25 +157,36 @@ func (m Model) renderFooter() string {
case modeAPIKeyRevokeByID:
return withStatus(" enter·revoke esc·back")
case modePasswordSet:
return withStatus(" tab·next field enter·set password esc·cancel")
default:
switch m.activeSection {
case sectionIncidents:
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit")
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionAlerts:
return withStatus(" enter·detail f·filter r·refresh tab·section q·quit")
return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionStats:
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
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 " + m.teamHint() + "tab·section r·refresh q·quit")
case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit")
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section q·quit")
}
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
}
}
// teamHint is the footer's team-switch key, shown only when there is a choice.
func (m Model) teamHint() string {
if len(m.teams) > 1 {
return "T·team "
}
return ""
}
func (m Model) confirmPrompt() string {
switch m.confirmTarget {
case confirmDeleteNote:
@@ -327,23 +351,41 @@ func (m Model) renderSchedule() string {
return "\n" + m.styles.Muted.Render(" Loading schedule…")
}
team, ok := m.scheduleTeam()
if !ok {
return "\n" + m.styles.Muted.Render(" You are not in any team, so there is no schedule to show.")
}
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
m.styles.AlertName.Render(m.currentOnCall.Username))
if len(m.currentOnCall) > 0 {
onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
} else {
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
}
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
return header + m.scheduleTable.View()
}
// onCallNames lists who is on call today. With several teams each name carries
// its team, since one person per team is on call and "alice, bob" alone would
// not say whose.
func (m Model) onCallNames() string {
parts := make([]string, len(m.currentOnCall))
for i, e := range m.currentOnCall {
parts[i] = e.Username
if len(m.teams) > 1 && e.TeamName != "" {
parts[i] += " (" + e.TeamName + ")"
}
}
return strings.Join(parts, ", ")
}
func (m Model) renderUserPicker() string {
if m.usersLoading {
return "\n" + m.styles.Muted.Render(" Loading users…")
@@ -436,6 +478,9 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
// Timing and ownership
if inc.TeamName != "" {
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
}
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
@@ -456,6 +501,10 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
b.WriteString(line(s.Muted, " Acked: not acknowledged"))
}
if inc.EscalationLevel > 0 {
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Escalation: level %d", inc.EscalationLevel)))
}
if inc.IsSnoozed() {
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
@@ -598,6 +647,8 @@ func eventLabel(e api.IncidentEvent) string {
// the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52)
case api.EventDeadmanSilent:
return " Dead man's switch went silent"
default:
label := " " + e.Type
if e.Detail != "" {
@@ -810,6 +861,28 @@ func (m Model) renderUserNotifyEdit() string {
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
}
func (m Model) renderPasswordSet() string {
header := fmt.Sprintf("\n Web UI password for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
if m.pwLoading {
return header + line(m.styles.Muted, " Checking who this key belongs to…")
}
labels := [pwFieldCount]string{
pwCurrent: " Current password: ",
pwNew: " New password: ",
pwRepeat: " Repeat: ",
}
var form string
for _, f := range m.pwFields() {
label := labels[f]
if f == m.pwFocus {
label = m.styles.Selected.Render(label)
}
form += label + m.pwInputs[f].View() + "\n"
}
hint := fmt.Sprintf(" At least %d characters. Setting it signs %s out of every other\n web UI session. API keys are not affected.", minPasswordLen, m.selectedUser.Username)
return header + form + "\n" + line(m.styles.Muted, hint)
}
func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
+1 -1
View File
@@ -46,7 +46,7 @@ func main() {
}
client := api.NewClient(cfg.ServerURL, cfg.APIKey)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).WithDefaultTeam(cfg.Team)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {