diff --git a/CLAUDE.md b/CLAUDE.md index 5f1e9e7..3b155ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | ## Memory (GrayMatter) diff --git a/README.md b/README.md index 36d119b..5519657 100644 --- a/README.md +++ b/README.md @@ -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 (triggered)` when ntfy accepted the page, and `Notification to 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: 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: @@ -196,5 +215,6 @@ Users section: | `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`. Setting passwords needs terdut-server **v0.10.2 or later**, the first -with a web UI. +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. diff --git a/internal/api/client.go b/internal/api/client.go index 38993d7..4b2c64e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -72,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) } @@ -138,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) } @@ -329,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 } @@ -338,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 } @@ -388,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 { @@ -464,7 +484,7 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error { } // Me returns the user the API key belongs to, and whether they have a web UI -// password. Needs terdut-server v0.10.2 or later; older servers answer 404. +// password. func (c *Client) Me() (*Me, error) { req, err := c.newRequest(http.MethodGet, "/api/me") if err != nil { @@ -488,7 +508,8 @@ func (c *Client) SetPassword(userID int64, password, current string) error { return c.do(req, nil) } -// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). +// 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 { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 5f3727b..d806f24 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -40,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" { @@ -108,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 { @@ -171,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) } @@ -181,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}` { @@ -247,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") } } @@ -340,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" { diff --git a/internal/api/types.go b/internal/api/types.go index 563ab3e..5ac10bd 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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,12 +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"` diff --git a/internal/config/config.go b/internal/config/config.go index 0129461..58d22e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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: \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: \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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..aa618e4 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index c29a75b..2674636 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "slices" + "strconv" + "strings" "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" @@ -108,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 } @@ -143,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 } @@ -185,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 @@ -242,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 @@ -252,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 @@ -395,6 +416,14 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio } } +// 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) } @@ -444,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)) } @@ -470,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) @@ -486,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)) @@ -536,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 { @@ -550,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 { @@ -618,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 } @@ -627,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 { @@ -651,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 { @@ -664,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)) @@ -763,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} } @@ -783,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} } @@ -795,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} } @@ -901,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} } @@ -915,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} } @@ -956,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() + return msg + } +} + +// 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 scheduleActionErrMsg{err} + return userActionErrMsg{err} } - return scheduleFetchedMsg{entries: entries, current: current} + 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} } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cf52263..314f31a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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 diff --git a/internal/tui/update.go b/internal/tui/update.go index 86a8bb7..59b9c4d 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -34,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: @@ -159,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 @@ -226,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 { @@ -410,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: @@ -450,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 @@ -465,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 @@ -482,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) @@ -489,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) @@ -518,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 @@ -528,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 @@ -545,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()) @@ -562,6 +626,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("API keys") + return m, cmd + } m.mode = modeAPIKeyMenu return m, nil @@ -574,6 +642,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("password") + return m, cmd + } for i := range m.pwInputs { m.pwInputs[i].Reset() m.pwInputs[i].Blur() @@ -589,13 +661,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { 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 { @@ -605,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 { @@ -641,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) @@ -649,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 { @@ -732,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 @@ -895,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: @@ -912,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)) } @@ -933,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 @@ -986,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)) } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 65f5dcd..2f8975e 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -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) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 2b3a64f..5337944 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 { @@ -152,15 +163,15 @@ func (m Model) renderFooter() string { 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 p·password r·refresh tab·section q·quit") } @@ -168,6 +179,14 @@ func (m Model) renderFooter() string { } } +// 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: @@ -332,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…") @@ -441,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))) @@ -461,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)))) @@ -603,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 != "" { diff --git a/main.go b/main.go index d618797..62b83f0 100644 --- a/main.go +++ b/main.go @@ -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 {