Follow terdut-server into teams: switch team, per-team schedule
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# terdut-tui
|
# 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
|
## 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,
|
assignee, snooze, notes and an append-only timeline. Many alerts to one incident,
|
||||||
correlated by Alertmanager's `groupKey`.
|
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:
|
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
|
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
|
||||||
non-destructive "not now" alternative.
|
non-destructive "not now" alternative.
|
||||||
@@ -25,7 +33,7 @@ non-destructive "not now" alternative.
|
|||||||
## Project layout
|
## 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/api/client.go REST API client — one method per endpoint
|
||||||
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
|
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
|
||||||
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
|
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
|
||||||
@@ -35,7 +43,7 @@ internal/tui/ Bubbletea UI
|
|||||||
view.go View() — pure rendering
|
view.go View() — pure rendering
|
||||||
keys.go keyMap (bubbles/key pattern)
|
keys.go keyMap (bubbles/key pattern)
|
||||||
styles.go Styles struct — every lipgloss style, built from a theme
|
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
|
## Architecture rules
|
||||||
@@ -57,6 +65,7 @@ server_url: https://terdut.example.com
|
|||||||
api_key: <64-char hex key>
|
api_key: <64-char hex key>
|
||||||
refresh_interval: 30 # seconds, optional, default 30
|
refresh_interval: 30 # seconds, optional, default 30
|
||||||
theme: gruvbox-dark # optional, default gruvbox-dark
|
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
|
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 |
|
| 4 | On-call schedule calendar view |
|
||||||
| 5 | User management and API key lifecycle |
|
| 5 | User management and API key lifecycle |
|
||||||
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
|
| 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 -->
|
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
|
||||||
## Memory (GrayMatter)
|
## Memory (GrayMatter)
|
||||||
|
|||||||
@@ -10,12 +10,33 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
|
|||||||
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
|
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
|
||||||
- **Timeline** — the full history of an incident, system events, pages and notes together
|
- **Timeline** — the full history of an incident, system events, pages and notes together
|
||||||
- **Alert feed** — the raw read-only alerts underneath, each linked to its incident
|
- **Alert feed** — the raw read-only alerts underneath, each linked to its incident
|
||||||
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries
|
- **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
|
- **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
|
- **User management** — add and remove users, manage API keys, set each user's ntfy topic
|
||||||
|
|
||||||
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API;
|
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
|
||||||
> use terdut-tui v0.3.x with those.
|
> 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
|
## 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
|
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.
|
of retries. That second one is the one to look for when nobody's phone rang.
|
||||||
|
|
||||||
Editing topics needs terdut-server **v0.6.0 or later**; the timeline entries need
|
|
||||||
**v0.7.0 or later**. Against an older server the topic column stays empty and
|
|
||||||
editing one reports the server's 404.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -70,6 +88,7 @@ server_url: https://terdut.example.com
|
|||||||
api_key: <your-api-key>
|
api_key: <your-api-key>
|
||||||
refresh_interval: 30 # seconds, optional
|
refresh_interval: 30 # seconds, optional
|
||||||
theme: gruvbox-dark # optional, this is the default
|
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.
|
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 |
|
| `esc` | Go back |
|
||||||
| `r` | Refresh |
|
| `r` | Refresh |
|
||||||
| `f` | Cycle filter |
|
| `f` | Cycle filter |
|
||||||
|
| `T` | Switch team: all → each of your teams (when you have more than one) |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
|
|
||||||
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
|
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
|
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
|
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
|
selection at once when you accept, so reassigning a week is one confirmation
|
||||||
rather than seven deletions. Taking somebody's shift needs terdut-server
|
rather than seven deletions. The header line names the team whose rota this is,
|
||||||
**v0.8.0 or later**; against an older server the assignment is refused with
|
and "On-call today" lists everyone on call across your teams.
|
||||||
`date already assigned`.
|
|
||||||
|
|
||||||
Users section:
|
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 |
|
| `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
|
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
|
than `k`. The Flags column marks administrators and disabled accounts. `n` and `d`
|
||||||
with a web UI.
|
are for administrators; `t`, `k` and `p` work on your own row, or on anyone's if you
|
||||||
|
are one.
|
||||||
|
|||||||
+59
-38
@@ -72,14 +72,18 @@ func (c *Client) do(req *http.Request, out any) error {
|
|||||||
return nil
|
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.
|
// 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
|
// 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
|
// archive here. This is the raw feed, useful for checking what Alertmanager is
|
||||||
// actually sending; the work queue is ListIncidents.
|
// 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{}
|
q := url.Values{}
|
||||||
|
if teamID > 0 {
|
||||||
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
||||||
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
q.Set("status", status)
|
q.Set("status", status)
|
||||||
}
|
}
|
||||||
@@ -138,12 +142,16 @@ func (c *Client) GetAlert(id int64) (*Alert, error) {
|
|||||||
|
|
||||||
// ── Incidents ──────────────────────────────────────────────────────────────
|
// ── 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
|
// "acknowledged", "resolved", or "" for the server default of open incidents
|
||||||
// only. archived and snoozed each switch the list to that set rather than
|
// only. archived and snoozed each switch the list to that set rather than
|
||||||
// adding to it, matching the server's filters.
|
// 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{}
|
q := url.Values{}
|
||||||
|
if teamID > 0 {
|
||||||
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
||||||
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
q.Set("status", status)
|
q.Set("status", status)
|
||||||
}
|
}
|
||||||
@@ -329,8 +337,37 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
|
|||||||
return result, c.do(req, &result)
|
return result, c.do(req, &result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
|
// ── Teams ──────────────────────────────────────────────────────────────────
|
||||||
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -338,49 +375,30 @@ func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
|
|||||||
return entries, c.do(req, &entries)
|
return entries, c.do(req, &entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled.
|
// GetCurrentOnCall returns today's on-call entries, one per team that has
|
||||||
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
|
// 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")
|
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resp, err := c.httpClient.Do(req)
|
var entries []ScheduleEntry
|
||||||
if err != nil {
|
return entries, c.do(req, &entries)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
// 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
|
// 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.
|
// 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 {
|
body := struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Dates []string `json:"dates"`
|
Dates []string `json:"dates"`
|
||||||
Replace bool `json:"replace,omitempty"`
|
Replace bool `json:"replace,omitempty"`
|
||||||
}{UserID: userID, Dates: dates, Replace: replace}
|
}{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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -388,14 +406,16 @@ func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]S
|
|||||||
return entries, c.do(req, &entries)
|
return entries, c.do(req, &entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DeleteScheduleEntry(id int64) error {
|
func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
|
||||||
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id))
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.do(req, nil)
|
return c.do(req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Users ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (c *Client) ListUsers() ([]User, error) {
|
func (c *Client) ListUsers() ([]User, error) {
|
||||||
req, err := c.newRequest(http.MethodGet, "/api/users")
|
req, err := c.newRequest(http.MethodGet, "/api/users")
|
||||||
if err != nil {
|
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
|
// 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) {
|
func (c *Client) Me() (*Me, error) {
|
||||||
req, err := c.newRequest(http.MethodGet, "/api/me")
|
req, err := c.newRequest(http.MethodGet, "/api/me")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,7 +508,8 @@ func (c *Client) SetPassword(userID int64, password, current string) error {
|
|||||||
return c.do(req, nil)
|
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 {
|
func (c *Client) HealthCheck() error {
|
||||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+99
-17
@@ -40,7 +40,7 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
|
|||||||
|
|
||||||
func TestClient_SendsBearerToken(t *testing.T) {
|
func TestClient_SendsBearerToken(t *testing.T) {
|
||||||
c, got := stub(t, http.StatusOK, `[]`)
|
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)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
if got.auth != "Bearer test-key" {
|
if got.auth != "Bearer test-key" {
|
||||||
@@ -108,23 +108,26 @@ func TestClient_IncidentEndpoints(t *testing.T) {
|
|||||||
func TestListIncidents_Filters(t *testing.T) {
|
func TestListIncidents_Filters(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
teamID int64
|
||||||
status string
|
status string
|
||||||
archived bool
|
archived bool
|
||||||
snoozed bool
|
snoozed bool
|
||||||
limit int
|
limit int
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"default is the open queue", "", false, false, 0, ""},
|
{"default is the open queue", 0, "", false, false, 0, ""},
|
||||||
{"status", "triggered", false, false, 0, "status=triggered"},
|
{"status", 0, "triggered", false, false, 0, "status=triggered"},
|
||||||
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"},
|
{"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
|
||||||
{"snoozed", "", false, true, 0, "snoozed=true"},
|
{"snoozed", 0, "", false, true, 0, "snoozed=true"},
|
||||||
{"limit", "", false, false, 500, "limit=500"},
|
{"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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
c, got := stub(t, http.StatusOK, `[]`)
|
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)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
if got.query != tt.want {
|
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.
|
// wire when asked for — and stay off it when not.
|
||||||
t.Run("assign schedule", func(t *testing.T) {
|
t.Run("assign schedule", func(t *testing.T) {
|
||||||
c, got := stub(t, http.StatusCreated, `[]`)
|
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)
|
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"]}` {
|
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
|
||||||
t.Errorf("unexpected body %q", got.body)
|
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) {
|
t.Run("assign schedule with replace", func(t *testing.T) {
|
||||||
c, got := stub(t, http.StatusCreated, `[]`)
|
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)
|
t.Fatalf("assign: %v", err)
|
||||||
}
|
}
|
||||||
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
|
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.
|
// Nobody on call is a normal state, not a failure: the server answers with an
|
||||||
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) {
|
// empty list, one entry per team that has somebody scheduled.
|
||||||
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`)
|
func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
|
||||||
entry, err := c.GetCurrentOnCall()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("on call: %v", err)
|
||||||
}
|
}
|
||||||
if entry != nil {
|
if got.path != "/api/schedule/current" {
|
||||||
t.Errorf("expected nil entry, got %+v", entry)
|
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) {
|
func TestListAlerts_ArchivedFilter(t *testing.T) {
|
||||||
c, got := stub(t, http.StatusOK, `[]`)
|
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)
|
t.Fatalf("list alerts: %v", err)
|
||||||
}
|
}
|
||||||
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
|
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
|
||||||
|
|||||||
+56
-3
@@ -7,6 +7,8 @@ import "time"
|
|||||||
// alert belongs to.
|
// alert belongs to.
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -25,7 +27,8 @@ type Alert struct {
|
|||||||
|
|
||||||
// ResolutionSource records why a resolved alert left the firing state:
|
// ResolutionSource records why a resolved alert left the firing state:
|
||||||
// "alertmanager" for a real resolved webhook, "expiry" when the server
|
// "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"`
|
ResolutionSource *string `json:"resolution_source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ const (
|
|||||||
// groupKey Alertmanager computed from the operator's group_by configuration.
|
// groupKey Alertmanager computed from the operator's group_by configuration.
|
||||||
type Incident struct {
|
type Incident struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
GroupKey string `json:"group_key"`
|
GroupKey string `json:"group_key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
GroupLabels map[string]string `json:"group_labels"`
|
GroupLabels map[string]string `json:"group_labels"`
|
||||||
@@ -63,10 +68,17 @@ type Incident struct {
|
|||||||
|
|
||||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
|
||||||
// ResolutionSource is "alerts" when every alert stopped firing, or "manual"
|
// ResolutionSource is "alerts" when every alert stopped firing, "manual"
|
||||||
// when a person closed it. Treat the value set as open.
|
// 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"`
|
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"`
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
|
||||||
// Alerts is populated by GET /api/incidents/{id} only.
|
// Alerts is populated by GET /api/incidents/{id} only.
|
||||||
@@ -96,6 +108,9 @@ const (
|
|||||||
EventResolved = "resolved"
|
EventResolved = "resolved"
|
||||||
EventNote = "note"
|
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.
|
// Written by the server's notifier from the delivery result, not at enqueue.
|
||||||
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
|
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
|
||||||
// and on a failure the reason after it. An absent user means the page went to
|
// 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 {
|
type ScheduleEntry struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
@@ -166,6 +183,14 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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
|
// 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
|
// 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
|
// 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
|
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.
|
// Me is GET /api/me: the caller, and whether they can sign in to the web UI.
|
||||||
type Me struct {
|
type Me struct {
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
HasPassword bool `json:"has_password"`
|
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 {
|
type APIKey struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type Config struct {
|
|||||||
APIKey string
|
APIKey string
|
||||||
RefreshInterval time.Duration
|
RefreshInterval time.Duration
|
||||||
Theme string
|
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 {
|
type rawConfig struct {
|
||||||
@@ -23,6 +27,7 @@ type rawConfig struct {
|
|||||||
APIKey string `yaml:"api_key"`
|
APIKey string `yaml:"api_key"`
|
||||||
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
|
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
|
||||||
Theme string `yaml:"theme,omitempty"`
|
Theme string `yaml:"theme,omitempty"`
|
||||||
|
Team string `yaml:"team,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -35,7 +40,7 @@ func Load() (*Config, error) {
|
|||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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)
|
return nil, fmt.Errorf("cannot read config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -62,5 +67,6 @@ func Load() (*Config, error) {
|
|||||||
APIKey: raw.APIKey,
|
APIKey: raw.APIKey,
|
||||||
RefreshInterval: interval,
|
RefreshInterval: interval,
|
||||||
Theme: raw.Theme,
|
Theme: raw.Theme,
|
||||||
|
Team: raw.Team,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+285
-60
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||||
@@ -108,7 +110,10 @@ func filterLabel(filter string) string {
|
|||||||
// ── Messages ───────────────────────────────────────────────────────────────
|
// ── Messages ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// dashboard
|
// dashboard
|
||||||
type connectedMsg struct{}
|
type connectedMsg struct {
|
||||||
|
teams []api.Team
|
||||||
|
me api.Me
|
||||||
|
}
|
||||||
type connectErrMsg struct{ err error }
|
type connectErrMsg struct{ err error }
|
||||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||||
@@ -143,7 +148,11 @@ type detailStatsErrMsg struct{ err error }
|
|||||||
// schedule
|
// schedule
|
||||||
type scheduleFetchedMsg struct {
|
type scheduleFetchedMsg struct {
|
||||||
entries []api.ScheduleEntry
|
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 scheduleFetchErrMsg struct{ err error }
|
||||||
type scheduleActionErrMsg struct{ err error }
|
type scheduleActionErrMsg struct{ err error }
|
||||||
@@ -185,6 +194,15 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height 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
|
// Connection & dashboard
|
||||||
connected bool
|
connected bool
|
||||||
loading bool
|
loading bool
|
||||||
@@ -242,7 +260,7 @@ type Model struct {
|
|||||||
scheduleWindow time.Time
|
scheduleWindow time.Time
|
||||||
scheduleEntries []api.ScheduleEntry
|
scheduleEntries []api.ScheduleEntry
|
||||||
scheduleDays []scheduleDay
|
scheduleDays []scheduleDay
|
||||||
currentOnCall *api.ScheduleEntry
|
currentOnCall []api.ScheduleEntry
|
||||||
scheduleLoading bool
|
scheduleLoading bool
|
||||||
scheduleTable table.Model
|
scheduleTable table.Model
|
||||||
|
|
||||||
@@ -252,6 +270,9 @@ type Model struct {
|
|||||||
userPickerTable table.Model
|
userPickerTable table.Model
|
||||||
pickerTarget pickerTarget
|
pickerTarget pickerTarget
|
||||||
pickerAssignWeek bool
|
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
|
// User management section
|
||||||
userManageTable table.Model
|
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 {
|
func (m Model) Init() tea.Cmd {
|
||||||
return connectCmd(m.client)
|
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() {
|
func (m *Model) rebuildIncidentTable() {
|
||||||
m.incidentTable.SetColumns(incidentColumns(m.width))
|
setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
|
||||||
setRows(&m.incidentTable, incidentRows(m.incidents))
|
|
||||||
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) rebuildTable() {
|
func (m *Model) rebuildTable() {
|
||||||
m.alertTable.SetColumns(alertColumns(m.width))
|
setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
|
||||||
setRows(&m.alertTable, alertRows(m.alerts))
|
|
||||||
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) rebuildArchivedTable() {
|
func (m *Model) rebuildArchivedTable() {
|
||||||
m.archivedTable.SetColumns(incidentColumns(m.width))
|
setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
|
||||||
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
|
|
||||||
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,8 +512,9 @@ func (m *Model) rebuildScheduleTable() {
|
|||||||
|
|
||||||
func (m *Model) rebuildUserPickerTable() {
|
func (m *Model) rebuildUserPickerTable() {
|
||||||
m.userPickerTable.SetColumns(userPickerColumns(m.width))
|
m.userPickerTable.SetColumns(userPickerColumns(m.width))
|
||||||
rows := make([]table.Row, len(m.users))
|
pickable := m.pickerUsers()
|
||||||
for i, u := range m.users {
|
rows := make([]table.Row, len(pickable))
|
||||||
|
for i, u := range pickable {
|
||||||
rows[i] = table.Row{u.Username, u.Email}
|
rows[i] = table.Row{u.Username, u.Email}
|
||||||
}
|
}
|
||||||
setRows(&m.userPickerTable, rows)
|
setRows(&m.userPickerTable, rows)
|
||||||
@@ -486,7 +529,7 @@ func (m *Model) rebuildUserManageTable() {
|
|||||||
if topic == "" {
|
if topic == "" {
|
||||||
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)
|
setRows(&m.userManageTable, rows)
|
||||||
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
||||||
@@ -536,6 +579,106 @@ func (m Model) detailViewportHeight() int {
|
|||||||
return h
|
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
|
// noteEvents filters a timeline down to the deletable entries, which is what
|
||||||
// the [ and ] cursor walks.
|
// the [ and ] cursor walks.
|
||||||
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
||||||
@@ -550,44 +693,67 @@ func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
|||||||
|
|
||||||
// ── Column definitions ─────────────────────────────────────────────────────
|
// ── 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
|
const sevW, statusW, timeW = 9, 15, 12
|
||||||
titleW := width/2 - 10
|
titleW := width/2 - 10
|
||||||
|
if showTeam {
|
||||||
|
titleW -= teamW + 2
|
||||||
|
}
|
||||||
if titleW < 20 {
|
if titleW < 20 {
|
||||||
titleW = 20
|
titleW = 20
|
||||||
}
|
}
|
||||||
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
||||||
assigneeW := width - sevW - titleW - statusW - timeW - 10
|
assigneeW := width - sevW - titleW - statusW - timeW - 10
|
||||||
|
if showTeam {
|
||||||
|
assigneeW -= teamW + 2
|
||||||
|
}
|
||||||
if assigneeW < 8 {
|
if assigneeW < 8 {
|
||||||
assigneeW = 8
|
assigneeW = 8
|
||||||
}
|
}
|
||||||
return []table.Column{
|
cols := []table.Column{
|
||||||
{Title: "Sev", Width: sevW},
|
{Title: "Sev", Width: sevW},
|
||||||
{Title: "Incident", Width: titleW},
|
{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
|
const statusW, timeW = 10, 12
|
||||||
nameW := width/2 - 14
|
nameW := width/2 - 14
|
||||||
|
if showTeam {
|
||||||
|
nameW -= teamW + 2
|
||||||
|
}
|
||||||
if nameW < 20 {
|
if nameW < 20 {
|
||||||
nameW = 20
|
nameW = 20
|
||||||
}
|
}
|
||||||
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
||||||
incW := width - nameW - statusW - 2*timeW - 10
|
incW := width - nameW - statusW - 2*timeW - 10
|
||||||
|
if showTeam {
|
||||||
|
incW -= teamW + 2
|
||||||
|
}
|
||||||
if incW < 8 {
|
if incW < 8 {
|
||||||
incW = 8
|
incW = 8
|
||||||
}
|
}
|
||||||
return []table.Column{
|
cols := []table.Column{{Title: "Name", Width: nameW}}
|
||||||
{Title: "Name", Width: nameW},
|
if showTeam {
|
||||||
{Title: "Status", Width: statusW},
|
cols = append(cols, table.Column{Title: "Team", Width: teamW})
|
||||||
{Title: "Started", Width: timeW},
|
|
||||||
{Title: "Last Seen", Width: timeW},
|
|
||||||
{Title: "Incident", Width: incW},
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func scheduleColumns(width int) []table.Column {
|
||||||
@@ -618,8 +784,9 @@ func userManageColumns(width int) []table.Column {
|
|||||||
createdW := 12
|
createdW := 12
|
||||||
usernameW := 25
|
usernameW := 25
|
||||||
topicW := 22
|
topicW := 22
|
||||||
// 8 = bubbles' Padding(0, 1) on each of the four cells.
|
flagsW := 14
|
||||||
emailW := width - usernameW - topicW - createdW - 8
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
||||||
|
emailW := width - usernameW - topicW - flagsW - createdW - 10
|
||||||
if emailW < 15 {
|
if emailW < 15 {
|
||||||
emailW = 15
|
emailW = 15
|
||||||
}
|
}
|
||||||
@@ -627,13 +794,14 @@ func userManageColumns(width int) []table.Column {
|
|||||||
{Title: "Username", Width: usernameW},
|
{Title: "Username", Width: usernameW},
|
||||||
{Title: "Email", Width: emailW},
|
{Title: "Email", Width: emailW},
|
||||||
{Title: "Ntfy Topic", Width: topicW},
|
{Title: "Ntfy Topic", Width: topicW},
|
||||||
|
{Title: "Flags", Width: flagsW},
|
||||||
{Title: "Created", Width: createdW},
|
{Title: "Created", Width: createdW},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Row builders ───────────────────────────────────────────────────────────
|
// ── Row builders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func incidentRows(incidents []api.Incident) []table.Row {
|
func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
rows := make([]table.Row, len(incidents))
|
rows := make([]table.Row, len(incidents))
|
||||||
for i, inc := range incidents {
|
for i, inc := range incidents {
|
||||||
@@ -651,12 +819,16 @@ func incidentRows(incidents []api.Incident) []table.Row {
|
|||||||
if assignee == "" {
|
if assignee == "" {
|
||||||
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)}
|
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertRows(alerts []api.Alert) []table.Row {
|
func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
rows := make([]table.Row, len(alerts))
|
rows := make([]table.Row, len(alerts))
|
||||||
for i, a := range alerts {
|
for i, a := range alerts {
|
||||||
@@ -664,11 +836,23 @@ func alertRows(alerts []api.Alert) []table.Row {
|
|||||||
if a.IncidentID != nil {
|
if a.IncidentID != nil {
|
||||||
incident = fmt.Sprintf("#%d", *a.IncidentID)
|
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}
|
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
|
||||||
}
|
}
|
||||||
return rows
|
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 {
|
func scheduleRows(days []scheduleDay) []table.Row {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
rows := make([]table.Row, len(days))
|
rows := make([]table.Row, len(days))
|
||||||
@@ -763,19 +947,38 @@ func humanSeconds(secs *float64) string {
|
|||||||
|
|
||||||
// ── Commands ───────────────────────────────────────────────────────────────
|
// ── 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 {
|
func connectCmd(client *api.Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := client.HealthCheck(); err != nil {
|
if err := client.HealthCheck(); err != nil {
|
||||||
return connectErrMsg{err}
|
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 {
|
return func() tea.Msg {
|
||||||
status, snoozed := incidentQuery(filter)
|
status, snoozed := incidentQuery(filter)
|
||||||
incidents, err := client.ListIncidents(status, false, snoozed, 500)
|
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fetchDataErrMsg{err}
|
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 {
|
return func() tea.Msg {
|
||||||
// Archived incidents are all resolved, so the status filter has to be
|
// Archived incidents are all resolved, so the status filter has to be
|
||||||
// widened past the server's open-only default or nothing comes back.
|
// 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 {
|
if err != nil {
|
||||||
return fetchDataErrMsg{err}
|
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 {
|
return func() tea.Msg {
|
||||||
status, archived := filter, false
|
status, archived := filter, false
|
||||||
if filter == "archived" {
|
if filter == "archived" {
|
||||||
status, archived = "", true
|
status, archived = "", true
|
||||||
}
|
}
|
||||||
alerts, err := client.ListAlerts(status, archived, 500)
|
alerts, err := client.ListAlerts(teamID, status, archived, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fetchDataErrMsg{err}
|
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
|
// archiveIncidentCmd archives from the list view, so it reloads the list rather
|
||||||
// than a detail pane.
|
// 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 {
|
return func() tea.Msg {
|
||||||
if _, err := client.ArchiveIncident(id); err != nil {
|
if _, err := client.ArchiveIncident(id); err != nil {
|
||||||
return actionErrMsg{err}
|
return actionErrMsg{err}
|
||||||
}
|
}
|
||||||
status, snoozed := incidentQuery(filter)
|
status, snoozed := incidentQuery(filter)
|
||||||
incidents, err := client.ListIncidents(status, false, snoozed, 500)
|
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return actionErrMsg{err}
|
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 {
|
return func() tea.Msg {
|
||||||
if err := client.UnarchiveIncident(id); err != nil {
|
if err := client.UnarchiveIncident(id); err != nil {
|
||||||
return actionErrMsg{err}
|
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 {
|
if err != nil {
|
||||||
return actionErrMsg{err}
|
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 {
|
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 {
|
if err != nil {
|
||||||
return scheduleFetchErrMsg{err}
|
return scheduleFetchErrMsg{err}
|
||||||
}
|
}
|
||||||
current, err := client.GetCurrentOnCall()
|
return msg
|
||||||
if err != nil {
|
|
||||||
return scheduleFetchErrMsg{err}
|
|
||||||
}
|
|
||||||
return scheduleFetchedMsg{entries: entries, current: current}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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}
|
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 {
|
if err != nil {
|
||||||
return scheduleActionErrMsg{err}
|
return scheduleActionErrMsg{err}
|
||||||
}
|
}
|
||||||
current, err := client.GetCurrentOnCall()
|
return msg
|
||||||
if err != nil {
|
|
||||||
return scheduleActionErrMsg{err}
|
|
||||||
}
|
|
||||||
return scheduleFetchedMsg{entries: entries, current: current}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
return func() tea.Msg {
|
||||||
if err := client.DeleteScheduleEntry(entryID); err != nil {
|
if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
|
||||||
return scheduleActionErrMsg{err}
|
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 {
|
if err != nil {
|
||||||
return scheduleActionErrMsg{err}
|
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 {
|
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-19
@@ -143,7 +143,7 @@ func TestIncidentRows(t *testing.T) {
|
|||||||
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
|
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
|
||||||
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
|
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
|
||||||
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
|
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
|
||||||
})
|
}, false)
|
||||||
if len(rows) != 3 {
|
if len(rows) != 3 {
|
||||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
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) {
|
func TestAlertRows_ShowIncidentLink(t *testing.T) {
|
||||||
id := int64(7)
|
id := int64(7)
|
||||||
rows := alertRows([]api.Alert{
|
rows := alertRows([]api.Alert{
|
||||||
{Name: "DiskFull", Status: "firing", IncidentID: &id},
|
{Name: "DiskFull", Status: "firing", IncidentID: &id},
|
||||||
{Name: "Orphan", Status: "resolved"},
|
{Name: "Orphan", Status: "resolved"},
|
||||||
})
|
}, false)
|
||||||
if rows[0][4] != "#7" {
|
if rows[0][4] != "#7" {
|
||||||
t.Errorf("expected #7, got %q", rows[0][4])
|
t.Errorf("expected #7, got %q", rows[0][4])
|
||||||
}
|
}
|
||||||
@@ -204,31 +227,31 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
|
|||||||
// the window width.
|
// the window width.
|
||||||
func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
||||||
for _, width := range []int{100, 110, 140, 200} {
|
for _, width := range []int{100, 110, 140, 200} {
|
||||||
for name, cols := range map[string][]int{
|
// Five cells each, or six once a Team column is added. userManageColumns
|
||||||
"incident": widths(incidentColumns(width)),
|
// is five cells as well: username, email, topic, flags, created.
|
||||||
"alert": widths(alertColumns(width)),
|
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
|
sum := 0
|
||||||
for _, w := range cols {
|
for _, w := range widths(tc.cols) {
|
||||||
sum += w
|
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 {
|
if sum+padding != width {
|
||||||
t.Errorf("%s columns at width %d sum to %d+%d = %d",
|
t.Errorf("%s columns at width %d sum to %d+%d = %d",
|
||||||
name, width, sum, padding, sum+padding)
|
name, width, sum, padding, sum+padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The users table is four cells, so its padding budget differs.
|
|
||||||
sum := 0
|
|
||||||
for _, w := range widths(userManageColumns(width)) {
|
|
||||||
sum += w
|
|
||||||
}
|
|
||||||
const userPadding = 8
|
|
||||||
if sum+userPadding != width {
|
|
||||||
t.Errorf("user columns at width %d sum to %d+%d = %d",
|
|
||||||
width, sum, userPadding, sum+userPadding)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +259,7 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
|||||||
// what must not happen is a negative or zero column.
|
// what must not happen is a negative or zero column.
|
||||||
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
|
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
|
||||||
for _, width := range []int{20, 40, 60} {
|
for _, width := range []int{20, 40, 60} {
|
||||||
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
|
cols := append(widths(incidentColumns(width, true)), widths(alertColumns(width, true))...)
|
||||||
cols = append(cols, widths(userManageColumns(width))...)
|
cols = append(cols, widths(userManageColumns(width))...)
|
||||||
for _, w := range cols {
|
for _, w := range cols {
|
||||||
if w < 1 {
|
if w < 1 {
|
||||||
@@ -287,6 +310,7 @@ func scheduledWeek(entries []api.ScheduleEntry) Model {
|
|||||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||||
m.width, m.height = 120, 40
|
m.width, m.height = 120, 40
|
||||||
m.connected = true
|
m.connected = true
|
||||||
|
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
||||||
m.activeSection = sectionSchedule
|
m.activeSection = sectionSchedule
|
||||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||||
m.scheduleEntries = entries
|
m.scheduleEntries = entries
|
||||||
|
|||||||
+177
-24
@@ -34,12 +34,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// ── Dashboard messages ────────────────────────────────────────────────
|
// ── Dashboard messages ────────────────────────────────────────────────
|
||||||
|
|
||||||
case connectedMsg:
|
case connectedMsg:
|
||||||
|
firstConnect := len(m.teams) == 0
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.err = nil
|
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(
|
return m, tea.Batch(
|
||||||
tickCmd(m.refreshInterval),
|
tickCmd(m.refreshInterval),
|
||||||
fetchIncidentsCmd(m.client, m.incidentFilter),
|
fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
|
||||||
fetchStatsCmd(m.client),
|
fetchStatsCmd(m.client),
|
||||||
|
statusCmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
case connectErrMsg:
|
case connectErrMsg:
|
||||||
@@ -159,6 +176,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.rebuildUserManageTable()
|
m.rebuildUserManageTable()
|
||||||
return m, nil
|
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:
|
case apiKeyCreatedMsg:
|
||||||
m.revealedAPIKey = msg.key
|
m.revealedAPIKey = msg.key
|
||||||
m.mode = modeAPIKeyReveal
|
m.mode = modeAPIKeyReveal
|
||||||
@@ -226,22 +254,32 @@ func (m Model) refreshActiveSection() tea.Cmd {
|
|||||||
|
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
case sectionIncidents:
|
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:
|
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:
|
case sectionStats:
|
||||||
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
|
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
|
||||||
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
|
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
|
||||||
case sectionArchived:
|
case sectionArchived:
|
||||||
return fetchArchivedIncidentsCmd(m.client)
|
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
|
||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
return m.fetchScheduleWindowCmd()
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
return fetchUsersCmd(m.client)
|
return fetchUsersCmd(m.client)
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// routeKey passes the key to the active component then to our handler.
|
||||||
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
@@ -410,14 +448,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
case sectionIncidents:
|
case sectionIncidents:
|
||||||
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
|
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, fetchIncidentsCmd(m.client, m.incidentFilter)
|
return m, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)
|
||||||
case sectionAlerts:
|
case sectionAlerts:
|
||||||
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
|
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, fetchAlertsCmd(m.client, m.alertFilter)
|
return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "T":
|
||||||
|
if !m.connected || len(m.teams) == 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.switchTeam()
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
case sectionIncidents:
|
case sectionIncidents:
|
||||||
@@ -450,14 +494,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.statusMsg = "resolve the incident before archiving it"
|
m.statusMsg = "resolve the incident before archiving it"
|
||||||
return m, clearStatusCmd()
|
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:
|
case sectionArchived:
|
||||||
i := m.archivedTable.Cursor()
|
i := m.archivedTable.Cursor()
|
||||||
if i < 0 || i >= len(m.archivedIncidents) {
|
if i < 0 || i >= len(m.archivedIncidents) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.archivedLoading = true
|
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
|
return m, nil
|
||||||
|
|
||||||
@@ -465,16 +509,18 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
case "left", "h":
|
case "left", "h":
|
||||||
if m.activeSection == sectionSchedule {
|
if m.activeSection == sectionSchedule {
|
||||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
|
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
|
||||||
m.scheduleLoading = true
|
cmd := m.fetchScheduleWindowCmd()
|
||||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
m.scheduleLoading = cmd != nil
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "right", "l":
|
case "right", "l":
|
||||||
if m.activeSection == sectionSchedule {
|
if m.activeSection == sectionSchedule {
|
||||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
||||||
m.scheduleLoading = true
|
cmd := m.fetchScheduleWindowCmd()
|
||||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
m.scheduleLoading = cmd != nil
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -482,6 +528,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
if m.activeSection != sectionSchedule || !m.connected {
|
if m.activeSection != sectionSchedule || !m.connected {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if !m.checkScheduleEditable() {
|
||||||
|
return m, clearStatusCmd()
|
||||||
|
}
|
||||||
m.pickerAssignWeek = false
|
m.pickerAssignWeek = false
|
||||||
return m.openUserPicker(pickerSchedule)
|
return m.openUserPicker(pickerSchedule)
|
||||||
|
|
||||||
@@ -489,6 +538,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
if m.activeSection != sectionSchedule || !m.connected {
|
if m.activeSection != sectionSchedule || !m.connected {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if !m.checkScheduleEditable() {
|
||||||
|
return m, clearStatusCmd()
|
||||||
|
}
|
||||||
m.pickerAssignWeek = true
|
m.pickerAssignWeek = true
|
||||||
return m.openUserPicker(pickerSchedule)
|
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) {
|
if cursor < 0 || cursor >= len(m.users) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if !m.isAdmin {
|
||||||
|
m.statusMsg = "only administrators can delete users"
|
||||||
|
return m, clearStatusCmd()
|
||||||
|
}
|
||||||
m.selectedUser = m.users[cursor]
|
m.selectedUser = m.users[cursor]
|
||||||
m.confirmTarget = confirmDeleteUser
|
m.confirmTarget = confirmDeleteUser
|
||||||
m.mode = modeConfirm
|
m.mode = modeConfirm
|
||||||
@@ -528,6 +584,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
if m.activeSection != sectionUsers || !m.connected {
|
if m.activeSection != sectionUsers || !m.connected {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if !m.isAdmin {
|
||||||
|
m.statusMsg = "only administrators can create users"
|
||||||
|
return m, clearStatusCmd()
|
||||||
|
}
|
||||||
m.userFormInputs[0].Reset()
|
m.userFormInputs[0].Reset()
|
||||||
m.userFormInputs[1].Reset()
|
m.userFormInputs[1].Reset()
|
||||||
m.userFormFocus = 0
|
m.userFormFocus = 0
|
||||||
@@ -545,6 +605,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.selectedUser = m.users[cursor]
|
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
|
// Prefilled with what they have, so editing a topic does not mean
|
||||||
// retyping it, and clearing one is a deliberate wipe.
|
// retyping it, and clearing one is a deliberate wipe.
|
||||||
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
|
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
|
||||||
@@ -562,6 +626,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.selectedUser = m.users[cursor]
|
m.selectedUser = m.users[cursor]
|
||||||
|
if !m.canManageUser(m.selectedUser) {
|
||||||
|
cmd := m.refuseUserAction("API keys")
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
m.mode = modeAPIKeyMenu
|
m.mode = modeAPIKeyMenu
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -574,6 +642,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.selectedUser = m.users[cursor]
|
m.selectedUser = m.users[cursor]
|
||||||
|
if !m.canManageUser(m.selectedUser) {
|
||||||
|
cmd := m.refuseUserAction("password")
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
for i := range m.pwInputs {
|
for i := range m.pwInputs {
|
||||||
m.pwInputs[i].Reset()
|
m.pwInputs[i].Reset()
|
||||||
m.pwInputs[i].Blur()
|
m.pwInputs[i].Blur()
|
||||||
@@ -589,13 +661,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
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.
|
// loadSectionIfEmpty fetches a section's data the first time it is opened.
|
||||||
func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
case sectionAlerts:
|
case sectionAlerts:
|
||||||
if len(m.alerts) == 0 {
|
if len(m.alerts) == 0 {
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return fetchAlertsCmd(m.client, m.alertFilter)
|
return fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
|
||||||
}
|
}
|
||||||
case sectionStats:
|
case sectionStats:
|
||||||
if !m.statsLoaded {
|
if !m.statsLoaded {
|
||||||
@@ -605,12 +684,13 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
|||||||
case sectionArchived:
|
case sectionArchived:
|
||||||
if len(m.archivedIncidents) == 0 {
|
if len(m.archivedIncidents) == 0 {
|
||||||
m.archivedLoading = true
|
m.archivedLoading = true
|
||||||
return fetchArchivedIncidentsCmd(m.client)
|
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
|
||||||
}
|
}
|
||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
if len(m.scheduleDays) == 0 {
|
if len(m.scheduleDays) == 0 {
|
||||||
m.scheduleLoading = true
|
cmd := m.fetchScheduleWindowCmd()
|
||||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
m.scheduleLoading = cmd != nil
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
if len(m.users) == 0 {
|
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) {
|
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
||||||
m.pickerTarget = target
|
m.pickerTarget = target
|
||||||
m.mode = modeUserPicker
|
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 {
|
if len(m.users) == 0 {
|
||||||
m.usersLoading = true
|
m.usersLoading = true
|
||||||
return m, fetchUsersCmd(m.client)
|
return m, fetchUsersCmd(m.client)
|
||||||
@@ -649,6 +741,63 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
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.
|
// nextFilter advances a filter cycle, wrapping at the end.
|
||||||
func nextFilter(cycle []string, current string) string {
|
func nextFilter(cycle []string, current string) string {
|
||||||
for i, f := range cycle {
|
for i, f := range cycle {
|
||||||
@@ -732,10 +881,10 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
if inc.ArchivedAt != nil {
|
if inc.ArchivedAt != nil {
|
||||||
m.archivedLoading = true
|
m.archivedLoading = true
|
||||||
m.mode = modeDashboard
|
m.mode = modeDashboard
|
||||||
return m, unarchiveIncidentCmd(m.client, inc.ID)
|
return m, unarchiveIncidentCmd(m.client, inc.ID, m.activeTeamID)
|
||||||
}
|
}
|
||||||
m.mode = modeDashboard
|
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":
|
case "c":
|
||||||
m.mode = modeNote
|
m.mode = modeNote
|
||||||
@@ -895,7 +1044,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.mode = modeDashboard
|
m.mode = modeDashboard
|
||||||
m.pendingDeleteEntry = nil
|
m.pendingDeleteEntry = nil
|
||||||
m.scheduleLoading = true
|
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))
|
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||||
|
|
||||||
case confirmDeleteUser:
|
case confirmDeleteUser:
|
||||||
@@ -912,7 +1062,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.scheduleLoading = true
|
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))
|
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,10 +1084,11 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
cursor := m.userPickerTable.Cursor()
|
cursor := m.userPickerTable.Cursor()
|
||||||
if cursor < 0 || cursor >= len(m.users) {
|
pickable := m.pickerUsers()
|
||||||
|
if cursor < 0 || cursor >= len(pickable) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
user := m.users[cursor]
|
user := pickable[cursor]
|
||||||
|
|
||||||
if m.pickerTarget == pickerIncidentAssignee {
|
if m.pickerTarget == pickerIncidentAssignee {
|
||||||
m.mode = modeIncidentDetail
|
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
|
// Nobody else loses anything, but the server rejects any date that
|
||||||
// already exists — including days this same person already holds, which
|
// already exists — including days this same person already holds, which
|
||||||
// is a no-op worth letting through silently.
|
// 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))
|
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func sized() Model {
|
|||||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||||
m.width, m.height = 120, 40
|
m.width, m.height = 120, 40
|
||||||
m.connected = true
|
m.connected = true
|
||||||
|
m.isAdmin = true // most handlers are being tested for what they do, not who may
|
||||||
return m
|
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: 1, Username: "niklas", Email: "n@example.com"},
|
||||||
{ID: 2, Username: "alex", Email: "a@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.rebuildUserPickerTable()
|
||||||
m.scheduleTable.SetCursor(dayIndex)
|
m.scheduleTable.SetCursor(dayIndex)
|
||||||
m.pickerAssignWeek = week
|
m.pickerAssignWeek = week
|
||||||
@@ -693,6 +696,8 @@ func containsAll(s string, subs ...string) bool {
|
|||||||
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
||||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||||
m.connected = true
|
m.connected = true
|
||||||
|
m.isAdmin = true
|
||||||
|
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
||||||
m.activeSection = sectionSchedule
|
m.activeSection = sectionSchedule
|
||||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
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 {
|
if m.mode != modeUserPicker {
|
||||||
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
|
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
|
m, _ = press(t, m, "enter") // panicked here
|
||||||
|
|
||||||
if m.mode == modeUserPicker {
|
if m.mode == modeUserPicker {
|
||||||
t.Fatal("enter left the picker open; the assignment never went anywhere")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+55
-9
@@ -27,10 +27,21 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
func (m Model) renderHeader() string {
|
func (m Model) renderHeader() string {
|
||||||
title := m.styles.Header.Render("terdut-tui")
|
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)
|
right := m.styles.Muted.Render(m.serverURL)
|
||||||
return spread(title, right, m.width)
|
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 {
|
func (m Model) renderTabs() string {
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range sectionNames {
|
for i, name := range sectionNames {
|
||||||
@@ -152,15 +163,15 @@ func (m Model) renderFooter() string {
|
|||||||
default:
|
default:
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
case sectionIncidents:
|
case sectionIncidents:
|
||||||
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit")
|
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||||
case sectionAlerts:
|
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:
|
case sectionStats:
|
||||||
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
|
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
|
||||||
case sectionArchived:
|
case sectionArchived:
|
||||||
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
|
return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
return withStatus(" n·new user t·topic d·delete k·API keys p·password 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")
|
||||||
}
|
}
|
||||||
@@ -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 {
|
func (m Model) confirmPrompt() string {
|
||||||
switch m.confirmTarget {
|
switch m.confirmTarget {
|
||||||
case confirmDeleteNote:
|
case confirmDeleteNote:
|
||||||
@@ -332,23 +351,41 @@ func (m Model) renderSchedule() string {
|
|||||||
return "\n" + m.styles.Muted.Render(" Loading schedule…")
|
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
|
var onCallLine string
|
||||||
if m.currentOnCall != nil {
|
if len(m.currentOnCall) > 0 {
|
||||||
onCallLine = fmt.Sprintf(" On-call today: %s",
|
onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
|
||||||
m.styles.AlertName.Render(m.currentOnCall.Username))
|
|
||||||
} else {
|
} else {
|
||||||
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
|
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
|
||||||
}
|
}
|
||||||
|
|
||||||
from := m.scheduleWindow
|
from := m.scheduleWindow
|
||||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||||
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
|
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
|
||||||
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
||||||
|
|
||||||
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
|
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
|
||||||
return header + m.scheduleTable.View()
|
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 {
|
func (m Model) renderUserPicker() string {
|
||||||
if m.usersLoading {
|
if m.usersLoading {
|
||||||
return "\n" + m.styles.Muted.Render(" Loading users…")
|
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")
|
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
|
||||||
|
|
||||||
// Timing and ownership
|
// Timing and ownership
|
||||||
|
if inc.TeamName != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
|
||||||
|
}
|
||||||
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
|
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
|
||||||
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
|
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"))
|
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() {
|
if inc.IsSnoozed() {
|
||||||
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
|
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))))
|
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.
|
// the only thing that says why nobody's phone rang.
|
||||||
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
|
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
|
||||||
notifiedTarget(who), e.Detail), 52)
|
notifiedTarget(who), e.Detail), 52)
|
||||||
|
case api.EventDeadmanSilent:
|
||||||
|
return " Dead man's switch went silent"
|
||||||
default:
|
default:
|
||||||
label := " " + e.Type
|
label := " " + e.Type
|
||||||
if e.Detail != "" {
|
if e.Detail != "" {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := api.NewClient(cfg.ServerURL, cfg.APIKey)
|
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())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user