Compare commits

...

6 Commits

Author SHA1 Message Date
Niklas Ye 36c00acf62 Set the chart's placeholder version to 0.28.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m1s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 23s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 9bf4c92 and 2b396d2, so a
tree heading for v0.28.0 doesn't say 0.27.0.
2026-09-26 09:19:35 +02:00
Niklas Ye 9d1df2b611 Show week numbers on the rota, and assign a whole week from them
The rota grid starts each row with its ISO week number, and for an owner
the number is a button: one tap opens a sheet for that week, showing who
holds each of its seven days, and puts one person on all of them. A rota
is usually handed out by the week, and seven taps on seven days was the
only way to do it short of the range form.

ISO 8601 numbering, because the grid already runs Monday to Sunday: week
1 is the one holding the year's first Thursday, which is taken from the
Thursday of the row so the year boundaries come out right (2025-12-29 is
week 1 of 2026, 2020-12-31 is week 53).

Days already past are left alone. Who was on call last Tuesday is a fact,
and "the whole week" should not rewrite it, so a half-elapsed week covers
the days still to come and the sheet says so; a week that is entirely
over has nothing to assign. By default the assignment replaces whoever
holds those days, as the day sheet does and the sheet states, and a
checkbox limits it to the days nobody has yet. The overhang into the
neighbouring month is part of the same week and is included.

Web UI only: the existing schedule endpoint already takes a list of
dates and a replace flag, so nothing changed on the server and there is
nothing to mirror in terdut-tui.
2026-09-26 09:19:35 +02:00
Niklas Ye 9bf4c92bfe Set the chart's placeholder version to 0.27.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m59s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 54s
Release / scan-image (push) Successful in 27s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 2b396d2 and dc92f51, so a
tree heading for v0.27.0 doesn't say 0.26.0.
2026-09-26 09:04:54 +02:00
Niklas Ye e616c82646 Show team members as a list, with who is on call and who cannot be paged
Team -> Members was a two-column table and an inline add form. It is now
a table in the style of Switches, Sources and Escalation: a status
badge, the member, their role, their next rota day, when they were last
active and when they joined. Adding a member and changing a role moved
into sheets, and removing one asks first.

The badge is the one that matters at 03:00: On call if the rota has them
today, Reachable if they have an ntfy topic, and Can't be paged when a
page to them would go nowhere -- no topic, or a disabled account -- with
the reason under their name. Not being pageable wins over being on call,
since an on-call person nobody can reach is the case worth seeing before
an incident finds it. The rules are the notifier's own. The topic itself
is never in the response, only whether one is set.

Last active is the newer of a member's newest session and API-key use,
and is shown to every member of the team like the rest of the list.
Rota days are UTC dates, and the page formats them as such so a day
cannot show up as the one before.

Removing a member leaves the rota days already assigned to them alone,
which the confirm says, so they are reassigned from the Rota tab rather
than silently dropped.

Demoting the last owner is now refused with 409, as removing them
already was: it was the same outcome by another route, a team with
nobody who can edit it.

API: GET /members gains status, on_call, next_shift, pageable, problem
and last_active_at; additive, no migration, and terdut-tui needs
nothing. POST /members answers 409 for the last-owner demotion.
2026-09-26 09:04:54 +02:00
Niklas Ye 2b396d22d6 Set the chart's placeholder version to 0.26.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m56s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 4s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as dc92f51 and e8d45f9, so a
tree heading for v0.26.0 doesn't say 0.25.0.
2026-09-26 08:28:24 +02:00
Niklas Ye 1f1faa437c Show the escalation ladder as a list, with who it would page and where it is
Team -> Escalation was the draft form on the page, which showed the
ladder only as inputs. It is now a table in the style of Switches and
Sources: a row per level with a status badge, who it pages, the wait
before the next level, and the open incidents currently waiting on it.
Below it, the repeat count, the fallback topic and when the ladder last
escalated (linking the incident). The editor moved into an "Edit ladder"
sheet, so a poll of the page underneath can no longer throw away half an
edit, and the page-level draft state went with it.

Targets are resolved to who they mean today, and the badge says what
would actually happen: Ready, Escalating (an unanswered incident has
climbed to level 2 or higher), or Pages nobody. The last is the one worth
seeing before an incident finds it: an empty rota, a person with no ntfy
topic or a disabled account each make a rung a silence with a number on
it, and the target says which. The rules are pageLevel's own, so the
page cannot promise a page the notifier would skip.

"Last escalated" comes from the escalated timeline events that already
exist, so there is no migration. Acknowledging or resolving takes an
incident off the ladder, so Escalating clears then while the history
stays.

API: GET /escalation gains status and waiting per level, username,
reachable and problem per target, and last_escalated_at and
last_escalated_incident_id. Output only and additive; PUT is unchanged
and terdut-tui needs nothing.
2026-09-26 08:28:24 +02:00
9 changed files with 954 additions and 117 deletions
+3 -3
View File
@@ -715,8 +715,8 @@ administrator who is not in the team gets the same `404` as anybody else.
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner | | `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken | | `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | | `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team | | `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list |
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` | | `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner |
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner | | `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them | | `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change | | `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
@@ -725,7 +725,7 @@ administrator who is not in the team gets the same `404` as anybody else.
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens | | `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once | | `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires | | `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none | | `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[], last_escalated_at?, last_escalated_incident_id?}`. Empty levels means the team has none. Each level also carries `status` (`ready`, `escalating` when an unanswered incident has climbed to it, `unreachable` when nobody on it could be woken), `waiting` (ids of the open incidents on it) and, per target, `username` (who it means today — the person on call, for a rota target), `reachable` and `problem`. The extra fields are output only; `PUT` takes the plain shape |
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it | | `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
| `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing | | `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing |
| `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent | | `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight: # appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.25.0 version: 0.28.0
appVersion: "v0.25.0" appVersion: "v0.28.0"
+185 -1
View File
@@ -346,10 +346,194 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
respond(w, http.StatusOK, escalationResponse(policy, teamID)) view, err := escalationStatus(r.Context(), db, teamID, policy)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, view)
} }
} }
// Level statuses, as the Escalation page colours them.
const (
levelReady = "ready"
levelEscalating = "escalating"
levelUnreachable = "unreachable"
)
// escalationTargetView is a target with who it means today and whether that
// person can actually be woken. The extra fields are output only: the PUT body
// is the plain escalationTargetJSON, and anything else in it is ignored.
type escalationTargetView struct {
escalationTargetJSON
// Username is who the target resolves to right now: the named person, or
// whoever the rota says is on call today. Empty when nobody is.
Username string `json:"username,omitempty"`
// Reachable is whether a page to this target would go anywhere, and Problem
// says why not when it would not — the same conditions pageLevel skips on.
Reachable bool `json:"reachable"`
Problem string `json:"problem,omitempty"`
}
type escalationLevelView struct {
Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Targets []escalationTargetView `json:"targets"`
// Status is unreachable when no target of the level could be woken — a rung
// that looks configured and pages nobody, which is worth seeing before an
// incident finds it — escalating when an unanswered incident has climbed to
// it, and ready otherwise.
Status string `json:"status"`
// Waiting lists the open, unacknowledged incidents currently on this level.
Waiting []int64 `json:"waiting"`
}
type escalationView struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelView `json:"levels"`
// LastEscalatedAt is when an incident of this team last moved up the ladder,
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
// when nothing ever has: a ladder nobody has needed yet.
LastEscalatedAt *time.Time `json:"last_escalated_at,omitempty"`
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id,omitempty"`
}
// escalationStatus is a team's ladder together with what it would do right now
// and what it has been doing. The resolution follows pageLevel's rules, so the
// page cannot promise a page that the notifier would skip.
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
base := escalationResponse(policy, teamID)
out := escalationView{
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
Levels: []escalationLevelView{},
}
if !policy.configured() {
return out, nil
}
onCall, err := currentOnCall(ctx, db, teamID)
if err != nil {
return out, err
}
type account struct {
username string
topic bool
disabled bool
}
accounts := map[int64]account{}
lookup := func(id int64) (account, error) {
if a, ok := accounts[id]; ok {
return a, nil
}
var a account
var topic *string
var disabledAt *int64
if err := db.QueryRowContext(ctx,
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &topic, &disabledAt); err != nil {
return a, err
}
a.topic = topic != nil && *topic != ""
a.disabled = disabledAt != nil
accounts[id] = a
return a, nil
}
waiting := map[int64][]int64{}
rows, err := db.QueryContext(ctx, `
SELECT id, escalation_level FROM incidents
WHERE team_id = $1 AND resolved_at IS NULL AND archived_at IS NULL
AND status = 'triggered' AND escalation_level > 0
ORDER BY id`, teamID)
if err != nil {
return out, err
}
for rows.Next() {
var id, level int64
if err := rows.Scan(&id, &level); err != nil {
rows.Close()
return out, err
}
waiting[level] = append(waiting[level], id)
}
rows.Close()
if err := rows.Err(); err != nil {
return out, err
}
for _, l := range base.Levels {
level := escalationLevelView{
Position: l.Position, TimeoutSeconds: l.TimeoutSeconds,
Targets: []escalationTargetView{}, Waiting: []int64{},
}
if w := waiting[l.Position]; w != nil {
level.Waiting = w
}
anyReachable := false
for _, t := range l.Targets {
view := escalationTargetView{escalationTargetJSON: t}
userID := t.UserID
if t.Kind == "oncall" {
userID = onCall
}
switch {
case userID == nil:
view.Problem = "nobody is on call today"
default:
a, err := lookup(*userID)
switch {
case err != nil:
view.Problem = "account not found"
case a.disabled:
view.Username, view.Problem = a.username, "account is disabled"
case !a.topic:
view.Username, view.Problem = a.username, "has no ntfy topic"
default:
view.Username, view.Reachable = a.username, true
}
}
anyReachable = anyReachable || view.Reachable
level.Targets = append(level.Targets, view)
}
switch {
case !anyReachable:
level.Status = levelUnreachable
case l.Position >= 2 && len(level.Waiting) > 0:
level.Status = levelEscalating
default:
level.Status = levelReady
}
out.Levels = append(out.Levels, level)
}
var incidentID, at int64
switch err := db.QueryRowContext(ctx, `
SELECT e.incident_id, e.created_at
FROM incident_events e JOIN incidents i ON i.id = e.incident_id
WHERE i.team_id = $1 AND e.type = $2
ORDER BY e.created_at DESC, e.id DESC LIMIT 1`, teamID, evEscalated).
Scan(&incidentID, &at); {
case err == sql.ErrNoRows:
case err != nil:
return out, err
default:
t := time.Unix(at, 0).UTC()
out.LastEscalatedAt, out.LastEscalatedIncidentID = &t, &incidentID
}
return out, nil
}
type escalationLevelJSON struct { type escalationLevelJSON struct {
Position int64 `json:"position"` Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"` TimeoutSeconds int64 `json:"timeout_seconds"`
+119
View File
@@ -383,3 +383,122 @@ func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
t.Errorf("a target with no topic should page nothing, paged %v", got) t.Errorf("a target with no topic should page nothing, paged %v", got)
} }
} }
// ---------------------------------------------------------------------------
// The ladder as the Escalation page reads it
// ---------------------------------------------------------------------------
type ladderLevel struct {
Status string `json:"status"`
Waiting []int64 `json:"waiting"`
Targets []struct {
Kind string `json:"kind"`
Username string `json:"username"`
Reachable bool `json:"reachable"`
Problem string `json:"problem"`
} `json:"targets"`
}
type ladderView struct {
Levels []ladderLevel `json:"levels"`
LastEscalatedAt *string `json:"last_escalated_at"`
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id"`
}
func readLadder(t *testing.T, s *ts) ladderView {
t.Helper()
var v ladderView
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &v)
return v
}
// Targets say who they mean today, so "whoever is on call" is a name and not a
// promise.
func TestEscalation_StatusResolvesTargets(t *testing.T) {
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
second := teamUser(t, s, "second", "terdut-second")
ladder(t, s, second, 0, "terdut-fallback")
v := readLadder(t, s)
if len(v.Levels) != 2 {
t.Fatalf("expected 2 levels, got %d", len(v.Levels))
}
if got := v.Levels[0].Targets[0]; got.Kind != "oncall" || got.Username != "admin" || !got.Reachable {
t.Errorf("the rota target should resolve to the person on call, got %+v", got)
}
if got := v.Levels[1].Targets[0]; got.Username != "second" || !got.Reachable {
t.Errorf("the named target should be reachable, got %+v", got)
}
if v.Levels[0].Status != "ready" || v.Levels[1].Status != "ready" || v.LastEscalatedAt != nil {
t.Errorf("an idle, healthy ladder is ready and has never escalated, got %+v", v)
}
}
// A rung that would page nobody is called out before an incident finds it.
func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
silent := teamUser(t, s, "silent", "terdut-silent")
ladder(t, s, silent, 0, "terdut-fallback")
// Nobody on call today, and the named person loses their topic.
s.exec(t, "DELETE FROM schedule_entries")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
v := readLadder(t, s)
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
}
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
if p := readLadder(t, s).Levels[1].Targets[0].Problem; p != "account is disabled" {
t.Errorf("a disabled account should say so, got %q", p)
}
}
// Where unanswered incidents are right now, and when the ladder last did its
// job.
func TestEscalation_StatusShowsWhoIsWaitingAndLastEscalation(t *testing.T) {
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
second := teamUser(t, s, "second", "terdut-second")
ladder(t, s, second, 0, "terdut-fallback")
postWebhook(t, s, []map[string]any{
amAlert("fp-wait", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.sweepNotify(t)
// On level 1 it is waiting, which is normal and not yet an escalation.
v := readLadder(t, s)
if len(v.Levels[0].Waiting) != 1 || v.Levels[0].Status != "ready" || v.LastEscalatedAt != nil {
t.Fatalf("a fresh incident waits on level 1 quietly, got %+v", v)
}
overdue(t, s, 1)
s.sweepNotify(t)
v = readLadder(t, s)
if v.Levels[1].Status != "escalating" || len(v.Levels[1].Waiting) != 1 || v.Levels[1].Waiting[0] != 1 {
t.Errorf("level 2 should be escalating with the incident on it, got %+v", v.Levels[1])
}
if v.LastEscalatedAt == nil || v.LastEscalatedIncidentID == nil || *v.LastEscalatedIncidentID != 1 {
t.Errorf("the escalation should be recorded, got %+v", v)
}
// Somebody answers: nothing is waiting, but the history stays.
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
v = readLadder(t, s)
if v.Levels[1].Status != "ready" || len(v.Levels[1].Waiting) != 0 || v.LastEscalatedAt == nil {
t.Errorf("an acknowledged incident stops waiting but stays in the history, got %+v", v)
}
}
// No ladder is a real answer, not an error.
func TestEscalation_StatusWithoutALadder(t *testing.T) {
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
v := readLadder(t, s)
if len(v.Levels) != 0 || v.LastEscalatedAt != nil {
t.Errorf("a team with no ladder should read as empty, got %+v", v)
}
}
+154
View File
@@ -0,0 +1,154 @@
package api_test
import (
"net/http"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
func testNotify() api.NotifyConfig {
return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute}
}
type memberView struct {
Username string `json:"username"`
Role string `json:"role"`
Status string `json:"status"`
OnCall bool `json:"on_call"`
NextShift *string `json:"next_shift"`
Pageable bool `json:"pageable"`
Problem string `json:"problem"`
LastActiveAt *string `json:"last_active_at"`
}
func readMembers(t *testing.T, s *ts) map[string]memberView {
t.Helper()
var list []memberView
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list)
out := map[string]memberView{}
for _, m := range list {
out[m.Username] = m
}
return out
}
// The list says who is on call, who could not be woken, and who is merely
// there — and an on-call person who cannot be paged is the red one.
func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) {
s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic
teamUser(t, s, "reachable", "terdut-reachable")
silent := teamUser(t, s, "silent", "terdut-silent")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
got := readMembers(t, s)
if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable {
t.Errorf("the person on call should read on call, got %+v", m)
}
if m := got["reachable"]; m.Status != "reachable" || m.OnCall {
t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m)
}
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" {
t.Errorf("no topic means they cannot be paged, got %+v", m)
}
// Being on call does not rescue an account that cannot be woken.
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'")
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
t.Errorf("an on-call person with no topic is the red case, got %+v", m)
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" {
t.Errorf("a disabled account should say so, got %+v", m)
}
}
// The next shift is the next day after today, not today itself.
func TestMembers_NextShiftIsAfterToday(t *testing.T) {
s, _ := notifyTS(t, testNotify())
tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{tomorrow}})
resp.Body.Close()
m := readMembers(t, s)["admin"]
if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow {
t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m)
}
teamUser(t, s, "idle", "terdut-idle")
if m := readMembers(t, s)["idle"]; m.NextShift != nil {
t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift)
}
}
// Last active is the newer of a session and an API key, and absent when neither
// has ever been used.
func TestMembers_LastActive(t *testing.T) {
s, _ := notifyTS(t, testNotify())
idle := teamUser(t, s, "idle", "terdut-idle")
if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil {
t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt)
}
old := time.Now().Add(-48 * time.Hour).Unix()
s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old)
s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at)
VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix())
m := readMembers(t, s)["idle"]
if m.LastActiveAt == nil {
t.Fatal("expected a last active time")
}
got, _ := time.Parse(time.RFC3339, *m.LastActiveAt)
if got.Unix() != old+3600 {
t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix())
}
}
// The last owner can be neither removed nor demoted; with another owner in
// place, both are fine.
func TestMembers_LastOwnerIsProtected(t *testing.T) {
s, _ := notifyTS(t, testNotify())
tm := newTeam(t, s, "red")
base := "/api/teams/" + id64(tm.id) + "/members"
// Creating a team makes the creator an owner too; step the admin out so
// "red-user" is the only one left.
resp := s.req(t, http.MethodDelete, base+"/1", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("removing the creator: %d", resp.StatusCode)
}
var members []map[string]any
decode(t, tm.call(http.MethodGet, base, nil), &members)
var owner int64
for _, m := range members {
if m["username"] == "red-user" {
owner = int64(m["user_id"].(float64))
}
}
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode)
}
resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode)
}
// A second owner frees the first to step down.
resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"})
resp.Body.Close()
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode)
}
}
+84 -8
View File
@@ -210,8 +210,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
} }
} }
// handleListTeamMembers names everybody in a team. Visible to any member: you // Member statuses, as the Members page colours them.
// can see who else is on the rota you are on. const (
memberOnCall = "oncall"
memberReachable = "reachable"
memberUnpageable = "unpageable"
)
// memberStatus is a team member with what matters about them at 03:00: whether
// they are on call, whether a page to them would go anywhere, and whether they
// have been around. The extra fields are output only.
type memberStatus struct {
models.TeamMember
// Status is unpageable when a page to them would go nowhere — even when
// they are on call, since that is the case that matters most — on_call when
// the rota has them today, reachable otherwise.
Status string `json:"status"`
OnCall bool `json:"on_call"`
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
NextShift *string `json:"next_shift,omitempty"`
// Pageable is whether they have an ntfy topic and an enabled account — the
// conditions pageLevel and the notifier skip on. Never the topic itself.
Pageable bool `json:"pageable"`
Problem string `json:"problem,omitempty"`
// LastActiveAt is the last time they used a session or an API key.
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
// handleListTeamMembers names everybody in a team, with their status. Visible to
// any member: you can see who else is on the rota you are on.
func handleListTeamMembers(db *sql.DB) http.HandlerFunc { func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
@@ -223,26 +255,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
} }
rows, err := db.QueryContext(r.Context(), ` rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at,
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
u.disabled_at IS NOT NULL,
GREATEST(
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
EXISTS (SELECT 1 FROM schedule_entries s
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
(SELECT MIN(date) FROM schedule_entries s
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
FROM team_members m FROM team_members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1 WHERE m.team_id = $1
ORDER BY u.username`, teamID) ORDER BY u.username`, teamID, todayUTC())
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
defer rows.Close() defer rows.Close()
members := []models.TeamMember{} members := []memberStatus{}
for rows.Next() { for rows.Next() {
var m models.TeamMember var m memberStatus
var joined int64 var joined, lastActive int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil { var hasTopic, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
m.JoinedAt = time.Unix(joined, 0).UTC() m.JoinedAt = time.Unix(joined, 0).UTC()
if lastActive > 0 {
t := time.Unix(lastActive, 0).UTC()
m.LastActiveAt = &t
}
switch {
case disabled:
m.Problem = "account is disabled"
case !hasTopic:
m.Problem = "has no ntfy topic"
}
m.Pageable = m.Problem == ""
switch {
case !m.Pageable:
m.Status = memberUnpageable
case m.OnCall:
m.Status = memberOnCall
default:
m.Status = memberReachable
}
members = append(members, m) members = append(members, m)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -281,6 +343,20 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
return return
} }
// Demoting the last owner is removing them by another route: the team
// would have nobody who can edit it.
if req.Role == models.RoleMember {
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if last {
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
return
}
}
_, err := db.ExecContext(r.Context(), ` _, err := db.ExecContext(r.Context(), `
INSERT INTO team_members (team_id, user_id, role) INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
+27 -4
View File
@@ -173,7 +173,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
transition: background 0.12s, border-color 0.12s, opacity 0.12s; transition: background 0.12s, border-color 0.12s, opacity 0.12s;
} }
.btn:hover { background: var(--surface-hover); } .btn:hover { background: var(--surface-hover); }
.btn:disabled { opacity: 0.55; cursor: default; } .btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); } .btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; } .btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
@@ -365,7 +365,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
gone right, which is what the muted default already says. */ gone right, which is what the muted default already says. */
.badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); } .badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); }
.badge.st-active { background: var(--ok-soft); color: var(--ok); } .badge.st-active { background: var(--ok-soft); color: var(--ok); }
.badge.st-quiet { background: var(--warn-soft); color: var(--warn); } .badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); }
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); } .badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); } .badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); } .badge.sev-info { background: var(--info-soft); color: var(--info); }
@@ -704,7 +707,7 @@ kbd {
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.table-scroll { overflow-x: auto; margin-top: 12px; } .table-scroll { overflow-x: auto; margin-top: 12px; }
.status-table th, .status-table td { white-space: nowrap; } .status-table th, .status-table td { white-space: nowrap; }
.status-table td:nth-child(2) { white-space: normal; min-width: 12em; } .status-table td.wrap { white-space: normal; min-width: 12em; }
.status-table .source-row td { border-bottom-style: dashed; } .status-table .source-row td { border-bottom-style: dashed; }
.status-table .source-row td:first-child { padding-left: 16px; } .status-table .source-row td:first-child { padding-left: 16px; }
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } .source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
@@ -770,7 +773,7 @@ kbd {
too alike down a column to read, so a day carries an initial in that too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */ of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; } .rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-wd { .rota-wd {
padding-bottom: 4px; text-align: center; padding-bottom: 4px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700; color: var(--muted); font-size: 11px; font-weight: 700;
@@ -784,6 +787,21 @@ kbd {
} }
button.rota-day { cursor: pointer; } button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); } button.rota-day:hover { background: var(--surface-2); }
/* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */
.rota-week {
display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
color: var(--faint);
}
button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } .rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); } .rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; } .rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
@@ -827,6 +845,11 @@ button.rota-day:hover { background: var(--surface-2); }
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } .ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
/* A target that would not wake anybody says why, in place: it is the reason a
level is red, and the thing to go and fix. */
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
/* An integration key is shown exactly once, so it should look like something /* An integration key is shown exactly once, so it should look like something
to act on rather than another row of text. */ to act on rather than another row of text. */
+10
View File
@@ -66,6 +66,16 @@ export function mondayOf(d) {
return r; return r;
} }
// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the
// year's first Thursday, which is what a rota that runs Monday to Sunday means
// by "week 40". Taken from the Thursday of d's week, whose year is the week's.
export function isoWeek(d) {
const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate());
thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7));
const jan4 = new Date(thu.getFullYear(), 0, 4);
return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7);
}
export function addDays(d, n) { export function addDays(d, n) {
const r = new Date(d); const r = new Date(d);
r.setDate(r.getDate() + n); r.setDate(r.getDate() + n);
+370 -99
View File
@@ -20,7 +20,7 @@
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js'; import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js'; import { state, currentTeam, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, initial, ago, when, duration } from './format.js'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team'); const view = () => document.getElementById('view-team');
@@ -52,12 +52,10 @@ let freshKey = null; // an integration key, shown once, until the view is left
export function show(route) { export function show(route) {
const next = route?.tab ?? null; const next = route?.tab ?? null;
// A different sub-section wants different data, so the old answer goes // A different sub-section wants different data, so the old answer goes
// rather than being shown under the new heading until the fetch lands. The // rather than being shown under the new heading until the fetch lands.
// ladder draft goes with it: it is an edit of the page being left.
if (next !== tab) { if (next !== tab) {
tab = next; tab = next;
data = null; data = null;
draft = null;
} }
if (!data) clear(view(), subnav(), spinner()); if (!data) clear(view(), subnav(), spinner());
refresh(); refresh();
@@ -185,7 +183,6 @@ function teamPicker() {
teamID = Number(select.value); teamID = Number(select.value);
data = null; data = null;
freshKey = null; freshKey = null;
draft = null;
refresh(); refresh();
}); });
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select); return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
@@ -243,6 +240,7 @@ function overview() {
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }); const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' }); const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
const longDayFmt = new Intl.DateTimeFormat(undefined, { const longDayFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', day: 'numeric', month: 'long', weekday: 'long', day: 'numeric', month: 'long',
}); });
@@ -284,11 +282,14 @@ function scheduleCard() {
const key = isoDate(d); const key = isoDate(d);
const e = byDate.get(key); const e = byDate.get(key);
const inMonth = d.getMonth() === month; const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username); if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today)); cells.push(dayCell(d, key, e, inMonth, today));
} }
const heads = []; const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
// Any Monday will do; this one is a Monday. // Any Monday will do; this one is a Monday.
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) })); heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
@@ -326,6 +327,23 @@ function scheduleCard() {
]; ];
} }
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
function weekCell(monday, byDate, today) {
const n = isoWeek(monday);
const current = isoDate(monday) <= today && today < isoDate(addDays(monday, 7));
const cls = `rota-week${current ? ' current' : ''}`;
const label = `Week ${n}`;
return isOwner()
? h('button', {
class: cls, type: 'button', text: String(n),
title: `${label} · assign somebody for the whole week`, 'aria-label': label,
onclick: () => weekSheet(monday, byDate),
})
: h('div', { class: cls, title: label, text: String(n) });
}
function dayCell(d, key, e, inMonth, today) { function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past'] const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
.filter(Boolean).join(' '); .filter(Boolean).join(' ');
@@ -381,6 +399,73 @@ function coverNote(byDate) {
' left this month with nobody on call.'); ' left this month with nobody on call.');
} }
// One week, in the sheet: who holds each day of it, and one person to put on
// all of them. Days already gone are left alone — who was on call last Tuesday
// is a fact, and "the whole week" should not rewrite it — and the week's
// overhang into the next month is included, since it is the same week.
function weekSheet(monday, byDate) {
const today = isoDate(new Date());
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const ahead = keys.filter((k) => k >= today);
const range = `${dayShortFmt.format(days[0])} – ${dayShortFmt.format(days[6])}`;
const who = memberSelect();
const onlyEmpty = h('input', { type: 'checkbox' });
const problem = h('p', { class: 'load-error', hidden: true });
const holders = h('div', { class: 'week-holders' }, days.map((d, i) => {
const e = byDate.get(keys[i]);
return h('span', {
class: `week-holder${keys[i] < today ? ' past' : ''}`,
title: `${keys[i]} · ${e ? e.username : 'nobody'}`,
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }));
}));
openSheet(() => [
h('h2', { class: 'sheet-title', text: `Week ${isoWeek(monday)}` }),
h('p', { class: 'sheet-text', text: range }),
holders,
ahead.length
? [
h('label', { class: 'sheet-pick' }, 'On call ', who),
h('label', { class: 'checkbox' }, onlyEmpty, ' Only fill days nobody has yet'),
h('p', { class: 'muted small' },
ahead.length < 7
? `Days already past are left alone, so this covers the ${ahead.length} still to come. `
: '',
'Anybody already on those days is replaced unless you tick the box.'),
]
: h('p', { class: 'muted', text: 'This whole week is already over.' }),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet() }),
ahead.length > 0 && h('button', {
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign week',
onclick: () => {
const dates = onlyEmpty.checked ? ahead.filter((k) => !byDate.has(k)) : ahead;
if (!who.value) {
problem.textContent = 'There is nobody in this team to assign.';
problem.hidden = false;
return;
}
if (!dates.length) {
problem.textContent = 'Every day still to come already has somebody.';
problem.hidden = false;
return;
}
closeSheet();
act(() => api.assignSchedule(teamID, Number(who.value), dates, !onlyEmpty.checked));
},
}),
),
]);
}
// One day, in the sheet: who has it, who should, and the way to empty it. This // One day, in the sheet: who has it, who should, and the way to empty it. This
// is where the per-row Clear button went — the grid has no room for thirty of // is where the per-row Clear button went — the grid has no room for thirty of
// them, and the day you want to change is the one you just tapped. // them, and the day you want to change is the one you just tapped.
@@ -457,57 +542,124 @@ function memberSelect(selected) {
// --- escalation ------------------------------------------------------------ // --- escalation ------------------------------------------------------------
// The ladder is edited as a whole and sent as a whole, because the API replaces const LEVEL_STATUS = {
// it wholesale: the levels are an order, and patching one rung would leave the ready: { label: 'Ready', hint: 'Somebody here can be woken.' },
// numbering of the others undecided. escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' },
let draft = null; unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' },
};
const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready');
// One target as the list shows it: who it means today, and why it would not
// wake them if it would not.
function targetLine(t) {
const label = t.kind === 'oncall'
? `On call${t.username ? ` · ${t.username}` : ''}`
: (t.username || 'Unknown person');
return h('div', { class: 'target-line' },
h('span', { text: label }),
t.problem && h('span', { class: 'target-problem', text: t.problem }));
}
function escalationCard() { function escalationCard() {
const esc = data.escalation; const esc = data.escalation || {};
if (!draft) { const levels = esc.levels || [];
draft = {
repeat_count: esc.repeat_count || 0, const rows = levels.map((l) => h('tr', {},
fallback_topic: esc.fallback_topic || '', h('td', {}, h('strong', { text: `Level ${l.position}` })),
levels: (esc.levels || []).map((l) => ({ h('td', {}, levelBadge(l.status)),
timeout_seconds: l.timeout_seconds, h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })), h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
h('td', { class: 'small' }, l.waiting?.length
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
: h('span', { class: 'muted', text: '—' })),
));
const facts = [];
if (levels.length) {
const n = esc.repeat_count || 0;
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
facts.push(esc.fallback_topic
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
: 'No fallback topic: after the last level the chain just ends.');
facts.push(esc.last_escalated_at
? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.']
: 'Nothing has needed to escalate yet.');
}
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }),
isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor,
text: levels.length ? 'Edit ladder' : 'Set up ladder',
})), })),
}; h('p', { class: 'muted small' },
} 'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
levels.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'),
...facts.map((f) => h('p', { class: 'muted small' }, f)),
);
}
const body = []; // The ladder is edited as a whole and sent as a whole, because the API replaces
if (!draft.levels.length) { // it wholesale: the levels are an order, and patching one rung would leave the
body.push(h('p', { class: 'muted' }, // numbering of the others undecided. The draft lives in the sheet, so a poll of
'No ladder. An unacknowledged incident re-pages the same person every ', // the page underneath cannot throw away half an edit.
'reminder interval and nobody else is woken.')); function openLadderEditor() {
} const esc = data.escalation || {};
const draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
})),
};
draft.levels.forEach((level, i) => { const body = h('div', { class: 'ladder-editor' });
body.push(h('div', { class: 'ladder-level' }, const problem = h('p', { class: 'load-error', hidden: true });
h('div', { class: 'ladder-head' },
h('strong', { text: `Level ${i + 1}` }),
isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => { draft.levels.splice(i, 1); render(); },
})),
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
level.timeout_seconds = secs;
}), ' before the next level'),
h('div', { class: 'ladder-targets' },
...level.targets.map((t, ti) => targetRow(level, t, ti)),
isOwner() && h('button', {
class: 'btn-sm', type: 'button', text: '+ target',
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
})),
));
});
if (isOwner()) { const paint = () => {
body.push(h('button', { const parts = [];
if (!draft.levels.length) {
parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.'));
}
draft.levels.forEach((level, i) => {
parts.push(h('div', { class: 'ladder-level' },
h('div', { class: 'ladder-head' },
h('strong', { text: `Level ${i + 1}` }),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => { draft.levels.splice(i, 1); paint(); },
})),
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
level.timeout_seconds = secs;
}), ' before the next level'),
h('div', { class: 'ladder-targets' },
...level.targets.map((t, ti) => targetRow(level, t, ti, paint)),
h('button', {
class: 'btn-sm', type: 'button', text: '+ target',
onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); },
})),
));
});
parts.push(h('button', {
class: 'btn-sm', type: 'button', text: '+ level', class: 'btn-sm', type: 'button', text: '+ level',
onclick: () => { onclick: () => {
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] }); draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
render(); paint();
}, },
})); }));
@@ -520,31 +672,43 @@ function escalationCard() {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all', type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; }, oninput: (e) => { draft.fallback_topic = e.target.value; },
}); });
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times')); parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback)); parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
body.push(h('button', { clear(body, ...parts);
class: 'btn', type: 'button', text: 'Save ladder', };
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }), paint();
}));
}
return h('div', { class: 'card' }, const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
h('h2', { text: 'Escalation' }), save.addEventListener('click', async () => {
h('p', { class: 'muted small' }, try {
'When a level’s wait passes and nobody has acknowledged, the next level is ', await api.setEscalation(teamID, draft);
'paged. Acknowledging or resolving stops it; snoozing pauses it.'), } catch (err) {
...body, problem.textContent = err.message;
); problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Edit ladder' }),
body,
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save),
]);
} }
function targetRow(level, target, index) { function targetRow(level, target, index, repaint) {
const kind = h('select', {}, const kind = h('select', {},
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }), h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' })); h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
kind.addEventListener('change', () => { kind.addEventListener('change', () => {
target.kind = kind.value; target.kind = kind.value;
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined; target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
render(); repaint();
}); });
const who = target.kind === 'user' const who = target.kind === 'user'
@@ -555,10 +719,10 @@ function targetRow(level, target, index) {
} }
return h('div', { class: 'target-row' }, kind, who, return h('div', { class: 'target-row' }, kind, who,
isOwner() && h('button', { h('button', {
class: 'btn-sm danger', type: 'button', text: '×', class: 'btn-sm danger', type: 'button', text: '×',
title: 'Remove this target', title: 'Remove this target',
onclick: () => { level.targets.splice(index, 1); render(); }, onclick: () => { level.targets.splice(index, 1); repaint(); },
})); }));
} }
@@ -577,7 +741,7 @@ function integrationsCard() {
const rows = (data.integrations || []).map((i) => const rows = (data.integrations || []).map((i) =>
h('tr', {}, h('tr', {},
h('td', {}, sourceBadge(i.status)), h('td', {}, sourceBadge(i.status)),
h('td', {}, h('td', { class: 'wrap' },
h('strong', { text: i.name }), h('strong', { text: i.name }),
h('div', { class: 'muted small', text: i.kind })), h('div', { class: 'muted small', text: i.kind })),
// When the key last posted, and when an alert last arrived on it. They // When the key last posted, and when an alert last arrived on it. They
@@ -741,7 +905,7 @@ const triggeredCell = (iso, incidentID) => {
function switchRows(sw) { function switchRows(sw) {
const main = h('tr', {}, const main = h('tr', {},
h('td', {}, switchBadge(sw.status)), h('td', {}, switchBadge(sw.status)),
h('td', {}, h('td', { class: 'wrap' },
h('strong', { text: sw.name }), h('strong', { text: sw.name }),
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))), sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)), h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
@@ -854,41 +1018,150 @@ function openNewSwitch() {
// --- members --------------------------------------------------------------- // --- members ---------------------------------------------------------------
function membersCard() { const MEMBER_STATUS = {
const rows = (data.members || []).map((m) => oncall: { label: 'On call', hint: 'The rota has them today.' },
h('tr', {}, reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
h('td', {}, h('strong', { text: m.username })), unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
h('td', { class: 'muted small', text: m.role }), };
h('td', {}, isOwner() && h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}), isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
})),
));
const memberBadge = (m) => {
const el = statusBadge(MEMBER_STATUS, m.status, 'reachable');
if (m.problem) el.title = `${MEMBER_STATUS.unpageable.hint} ${m.problem}.`;
return el;
};
// Rota days are UTC dates with no time in them; formatting one in the viewer's
// zone could show the day before.
const shiftFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC',
});
const shiftDay = (ymd) => shiftFmt.format(new Date(`${ymd}T00:00:00Z`));
function shiftCell(m) {
if (m.on_call) {
return h('span', { text: m.next_shift ? `today, then ${shiftDay(m.next_shift)}` : 'today' });
}
return m.next_shift
? h('span', { text: shiftDay(m.next_shift) })
: h('span', { text: 'not scheduled' });
}
function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
const rows = members.map((m) => {
const lastOwner = m.role === 'owner' && owners === 1;
return h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
m.problem && h('div', { class: 'target-problem', text: m.problem })),
h('td', { class: 'muted small', text: m.role }),
h('td', { class: 'muted small' }, shiftCell(m)),
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Edit', onclick: () => openEditMember(m),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: lastOwner,
title: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => {
if (!(await confirm({
title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not '
+ 'changed, so reassign those from the Rota tab.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}))),
);
});
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
);
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
// choosing a person and a role; editing is the same with the person fixed. The
// API is one call either way — POST upserts the role.
function openMemberSheet({ title, submit, person, role, run }) {
const roleSelect = h('select', {},
...['member', 'owner'].map((r) => h('option', { value: r, text: r, selected: r === role })));
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
person.label,
h('label', {}, 'Role ', roleSelect),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await run(person.userID(), roleSelect.value);
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
}
function openAddMember() {
const inTeam = new Set((data.members || []).map((m) => m.user_id)); const inTeam = new Set((data.members || []).map((m) => m.user_id));
const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at); const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at);
const pick = h('select', {}, const pick = h('select', {},
...candidates.map((u) => h('option', { value: String(u.id), text: u.username }))); ...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
const role = h('select', {}, openMemberSheet({
h('option', { value: 'member', text: 'member' }), title: 'Add member', submit: 'Add member', role: 'member',
h('option', { value: 'owner', text: 'owner' })); person: {
const form = h('form', { class: 'inline-form' }, pick, role, label: candidates.length
h('button', { class: 'btn', type: 'submit', text: 'Add' })); ? h('label', {}, 'Person ', pick)
form.addEventListener('submit', (e) => { : h('p', { class: 'muted', text: 'Everybody with an account is already in this team.' }),
e.preventDefault(); userID: () => Number(pick.value),
act(() => api.addTeamMember(teamID, Number(pick.value), role.value)); },
run: (userID, role) => {
if (!userID) throw new Error('Nobody to add');
return api.addTeamMember(teamID, userID, role);
},
}); });
}
return h('div', { class: 'card' }, function openEditMember(m) {
h('h2', { text: 'Members' }), openMemberSheet({
h('table', { class: 'admin-table' }, h('tbody', {}, rows)), title: `Edit ${m.username}`, submit: 'Save', role: m.role,
isOwner() && candidates.length > 0 && form, person: { label: h('p', { class: 'muted small', text: m.username }), userID: () => m.user_id },
); run: (userID, role) => api.addTeamMember(teamID, userID, role),
});
} }
// --- plumbing -------------------------------------------------------------- // --- plumbing --------------------------------------------------------------
@@ -896,14 +1169,12 @@ function membersCard() {
// act runs a write and reloads. Errors are shown rather than thrown away: a // act runs a write and reloads. Errors are shown rather than thrown away: a
// 409 from the last-owner guard or the schedule's conflict rule is the server // 409 from the last-owner guard or the schedule's conflict rule is the server
// explaining itself, and the reader needs to see it. // explaining itself, and the reader needs to see it.
async function act(fn, { resetDraft = false } = {}) { async function act(fn) {
try { try {
await fn(); await fn();
error = null; error = null;
if (resetDraft) draft = null;
} catch (err) { } catch (err) {
error = err.message; error = err.message;
} }
if (!resetDraft) draft = null;
await refresh(); await refresh();
} }