Compare commits
23 Commits
v0.15.1
...
3ee8583f6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee8583f6f | |||
| 591d5b8df0 | |||
| d2cdcc9776 | |||
| 8b2789b9b2 | |||
| 71d7e1853a | |||
| 60ebb75cd2 | |||
| 734cd9c5fd | |||
| 423ed9b3a3 | |||
| 43f004499b | |||
| 559be6de6e | |||
| e77f04b55e | |||
| e536fdd2c0 | |||
| 429d5fdda3 | |||
| 3cdd5aee1f | |||
| 6a03698f65 | |||
| 67d68ce058 | |||
| a6fa673e08 | |||
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 | |||
| 828cf87656 | |||
| ac9af8e4f5 |
@@ -47,12 +47,13 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
||||
|
||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||
archive), who is on call, the alert feed, and changing your own password. It is
|
||||
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||
side. Schedule editing, statistics and user management remain in
|
||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||
to a sidebar with the queue and the incident side by side. The Stats page shows
|
||||
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
|
||||
chosen range.
|
||||
|
||||
You sign in with a username and password. Users have no password until one is
|
||||
set, and a user without one can only use API keys:
|
||||
@@ -85,15 +86,51 @@ How a browser stays signed in:
|
||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||
the web UI (`/incidents/{id}`).
|
||||
|
||||
A **Team** tab holds everything a team owns: the on-call rota, the escalation
|
||||
ladder, the alert sources with their keys, the dead man's switches and the
|
||||
membership. An owner edits it; a member sees the same page read-only, because
|
||||
the server refuses their writes anyway. Somebody in more than one team picks
|
||||
between them at the top.
|
||||
A **Team** tab holds everything a team owns, in five sub-sections with a URL
|
||||
each and a strip across the top to move between them: the on-call rota
|
||||
(`/team/rota`), the membership (`/team/members`), the escalation ladder
|
||||
(`/team/escalation`), the alert sources with their keys (`/team/sources`) and
|
||||
the dead man's switches (`/team/deadman`). `/team` itself is an overview — who
|
||||
is on call today, how many members and owners, how many ladder levels, how many
|
||||
keys and how many switches — so a page fetches only what it shows. An owner
|
||||
edits it; a member sees the same pages read-only, because the server refuses
|
||||
their writes anyway. Somebody in more than one team picks between them above
|
||||
the strip, since the choice changes the subject of all five.
|
||||
|
||||
The rota is a month at a time, one coloured initial per day with a legend
|
||||
underneath, and it says how many days are left uncovered — the question a rota
|
||||
is read for is who holds which stretch, and a run of one colour answers it
|
||||
where a list of dates does not. An owner taps a day to hand it to somebody or
|
||||
empty it, and fills a whole shift from the range form folded in below.
|
||||
|
||||
The **Admin** tab appears only for a system administrator, and holds what
|
||||
belongs to the whole server rather than to one team: every team, every user, and
|
||||
the settings that used to be environment variables.
|
||||
belongs to the whole server rather than to one team. It has three sub-sections,
|
||||
each with a URL of its own and a strip across the top to move between them:
|
||||
every team (`/admin/teams`), every user (`/admin/users`), and the settings that
|
||||
used to be environment variables (`/admin/settings`). `/admin` itself is an
|
||||
overview — how many of each, and what each section is for. Adding somebody is
|
||||
minting them an invite link into a team, rather than creating a bare account:
|
||||
the person who accepts it picks their own password, so one never passes through
|
||||
an administrator, and the link carries the team, so they land somewhere with a
|
||||
queue in it. That happens on the team's own page, since an invite is a fact
|
||||
about a team; the user list points there rather than asking which team beside a
|
||||
form.
|
||||
|
||||
A name in the team list opens **that team's page**, at `/admin/teams/{id}`: when it
|
||||
was created, how many are in it and how much is open, a field to rename it, the
|
||||
members with their roles, the invites into it, and deletion. The member list is the
|
||||
one thing there that needed a new endpoint — `GET /api/teams/{id}/members` is
|
||||
member-only and answers `404` to an administrator who is not in the team, which is
|
||||
the rule and not an oversight, so the page reads `GET /api/admin/teams/{id}` instead.
|
||||
An administrator still sees none of that team's incidents, alerts or rota.
|
||||
|
||||
A name in the user list opens **that person's page**, at `/admin/users/{id}`: their
|
||||
email and when they joined, where their notifications go, whether they are an
|
||||
administrator, whether the account is disabled, the teams they are in with their
|
||||
role in each, a password field for a first or forgotten one, and deletion. It is
|
||||
the one place membership is edited from the person's side — the Team tab answers
|
||||
"who is in this team", and answering "which teams is this person in" there means
|
||||
visiting each team in turn.
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -339,10 +376,20 @@ exactly as it was rather than with a hole in it.
|
||||
### Push notifications
|
||||
|
||||
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
||||
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with
|
||||
`PUT /api/users/{id}/notify`; a user with no topic falls back to
|
||||
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
|
||||
If neither yields a topic, nothing is queued.
|
||||
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
|
||||
under *Account* in the web UI, where a **Send a test push** button proves it
|
||||
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
|
||||
the API, and an administrator may set somebody else's. A user with no topic
|
||||
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
|
||||
nobody on call. If neither yields a topic, nothing is queued.
|
||||
|
||||
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
|
||||
something a user picks. Only the topic is per-person.
|
||||
|
||||
A topic is a shared secret with the ntfy server: anyone who knows it can both
|
||||
read the pages and publish to it, so an unguessable one is worth the trouble.
|
||||
That is also why the topic never appears in an incident's timeline, which every
|
||||
API key can read.
|
||||
|
||||
Three things get pushed:
|
||||
|
||||
@@ -579,10 +626,16 @@ Endpoints that require the flag answer `403` with
|
||||
|
||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
||||
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
||||
sees exactly the teams they belong to — an administrator is not implicitly in
|
||||
every team, because administration is about accounts, not about reading other
|
||||
people's incidents. Within a team an **owner** configures it (schedule,
|
||||
integrations, membership) and a **member** works its incidents.
|
||||
sees exactly the teams they belong to. Within a team an **owner** configures it
|
||||
(schedule, integrations, membership) and a **member** works its incidents.
|
||||
|
||||
An administrator crosses that line in one direction only. They **configure any
|
||||
team** without being in it — every owner-only endpoint accepts the flag, because
|
||||
otherwise a team whose last owner left could never be repaired. They do **not
|
||||
read any team**: the queue, the alerts and the incidents are filtered by real
|
||||
membership, so an administrator sees a team's work only by joining it, which is
|
||||
a membership change and shows up as one. Administration is about accounts and
|
||||
the shape of a team, not about reading other people's incidents.
|
||||
|
||||
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
||||
incident exists is itself something only its team should learn.
|
||||
@@ -607,6 +660,7 @@ on anybody's.
|
||||
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
|
||||
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
||||
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||
@@ -621,6 +675,7 @@ on anybody's.
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
|
||||
| `GET` | `/api/admin/teams/{teamID}` | **admin** | One team and who is in it: `{"team", "members"}`. `404` for a team that does not exist. `GET /api/teams/{teamID}/members` is **member**-only and still `404`s an administrator from outside the team — reading a team's shape and reading its work are different questions, so they are different endpoints |
|
||||
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
|
||||
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
|
||||
|
||||
@@ -641,6 +696,11 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
||||
|
||||
### Teams
|
||||
|
||||
**owner** below means an owner of that team *or* a system administrator, who
|
||||
passes every one of these without being a member — see
|
||||
[Authentication](#authentication). **member** means membership and nothing else: an
|
||||
administrator who is not in the team gets the same `404` as anybody else.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# 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
|
||||
# metadata and drives nothing.
|
||||
version: 0.15.1
|
||||
appVersion: "v0.15.1"
|
||||
version: 0.23.0
|
||||
appVersion: "v0.23.0"
|
||||
|
||||
@@ -237,6 +237,246 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. It has been true
|
||||
// since teams landed and nothing pinned it, so a later reading of the epic's
|
||||
// "an admin is not implicitly in every team" could quietly take it away.
|
||||
//
|
||||
// The line it draws: configuring a team, yes; reading what the team owns, no.
|
||||
// The queue below is the half that stays shut.
|
||||
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// A team the admin is deliberately not a member of. It is created by
|
||||
// somebody else, so the admin's only claim on it is the flag.
|
||||
_, call := member(t, s, "founder")
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||
if team.ID == 0 {
|
||||
t.Fatal("no team was created")
|
||||
}
|
||||
|
||||
var mine []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
|
||||
for _, m := range mine {
|
||||
if m.ID == team.ID {
|
||||
t.Fatalf("the admin should not be a member of team %d", team.ID)
|
||||
}
|
||||
}
|
||||
|
||||
path := "/api/teams/" + id64(team.ID)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
want int
|
||||
}{
|
||||
{"rename it", http.MethodPut, path,
|
||||
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
|
||||
{"mint an invite", http.MethodPost, path + "/invites",
|
||||
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
|
||||
{"add a member", http.MethodPost, path + "/members",
|
||||
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
|
||||
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
|
||||
} {
|
||||
resp := s.req(t, c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != c.want {
|
||||
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the rule. An incident in that team is not the admin's
|
||||
// to read, because administration is about accounts — and the last case
|
||||
// above has just taken the admin back out of the membership.
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, path+"/integrations",
|
||||
map[string]string{"name": "theirs alertmanager"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
|
||||
|
||||
var incidents []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
|
||||
if len(incidents) != 0 {
|
||||
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// The team page at /admin/teams/{id} needs the one question the test above
|
||||
// leaves shut: who is in a team the administrator is not in.
|
||||
//
|
||||
// It is answered by a separate endpoint under AdminOnly rather than by letting
|
||||
// the admin flag through requireTeamMember, and the second half of this test is
|
||||
// the reason — /api/teams/{id}/members must keep answering 404, so that "member
|
||||
// means membership and nothing else" stays true of the endpoint it was said
|
||||
// about. Reading a team's shape and reading a team's work are different things.
|
||||
func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
founderID, call := member(t, s, "founder")
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||
if team.ID == 0 {
|
||||
t.Fatal("no team was created")
|
||||
}
|
||||
|
||||
// The admin reads it whole, without being in it.
|
||||
var got struct {
|
||||
Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
} `json:"team"`
|
||||
Members []struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
} `json:"members"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||
|
||||
if got.Team.ID != team.ID || got.Team.Name != "theirs" {
|
||||
t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name)
|
||||
}
|
||||
if got.Team.Members != 1 {
|
||||
t.Errorf("expected a member count of 1, got %d", got.Team.Members)
|
||||
}
|
||||
if len(got.Members) != 1 {
|
||||
t.Fatalf("expected one member, got %d", len(got.Members))
|
||||
}
|
||||
if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" {
|
||||
t.Errorf("expected founder (%d), got %q (%d)",
|
||||
founderID, got.Members[0].Username, got.Members[0].UserID)
|
||||
}
|
||||
// Whoever creates a team owns it, and the page's role toggle depends on
|
||||
// that being reported rather than assumed.
|
||||
if got.Members[0].Role != "owner" {
|
||||
t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role)
|
||||
}
|
||||
|
||||
// The rule this endpoint exists in order not to break. Same admin, same
|
||||
// team, the member-only endpoint: still not found.
|
||||
resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the new one is administration, not membership: being in the team is
|
||||
// not enough.
|
||||
resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound},
|
||||
{"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest},
|
||||
} {
|
||||
resp := s.req(t, http.MethodGet, c.path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != c.want {
|
||||
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A team name is trimmed when it is created, and renaming had not been, so " "
|
||||
// was a legal name to rename to and an illegal one to start with.
|
||||
func TestRenameTeam_TrimsTheName(t *testing.T) {
|
||||
s := newTS(t)
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team)
|
||||
|
||||
path := "/api/teams/" + id64(team.ID)
|
||||
resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a blank name must be refused, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Team struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"team"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||
if got.Team.Name != "padded" {
|
||||
t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// The admin page's per-user view asks what somebody is in. Self or admin, like
|
||||
// the rest of the per-user endpoints.
|
||||
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "joiner")
|
||||
path := "/api/users/" + id64(memberID) + "/teams"
|
||||
|
||||
// member() puts them in the default team, so both readings agree on one.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
do func() *http.Response
|
||||
}{
|
||||
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
|
||||
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
|
||||
} {
|
||||
var teams []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
decode(t, c.do(), &teams)
|
||||
if len(teams) != 1 {
|
||||
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
|
||||
}
|
||||
if teams[0].Role != "member" {
|
||||
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's is not theirs to read.
|
||||
otherID, _ := member(t, s, "nosy")
|
||||
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A user who does not exist is a 404 rather than an empty list, which is
|
||||
// how the page tells "no teams" from "no such person".
|
||||
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
@@ -384,10 +384,10 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
||||
|
||||
var id int64
|
||||
err = q.QueryRowContext(ctx, `
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||
RETURNING id`,
|
||||
teamID, groupKey, title, string(labelsJSON), severity,
|
||||
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||
time.Now().Unix(), onCall).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -36,6 +36,10 @@ const (
|
||||
evUnsnoozed = "unsnoozed"
|
||||
evResolved = "resolved"
|
||||
evNote = "note"
|
||||
// evResolutionNote is the note worth finding again: what fixed it. The
|
||||
// similar-incidents lookup and the page lead with these; plain notes are
|
||||
// the working chatter and stay one click away.
|
||||
evResolutionNote = "resolution_note"
|
||||
evDeadmanSilent = "deadman_silent"
|
||||
)
|
||||
|
||||
@@ -295,6 +299,34 @@ func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64,
|
||||
return id, err
|
||||
}
|
||||
|
||||
// volatileLabels say where a problem ran this time, not what the problem is, so
|
||||
// they stay out of the signature. Migration 008's backfill lists the same set.
|
||||
var volatileLabels = map[string]bool{
|
||||
"instance": true, "pod": true, "pod_name": true, "pod_ip": true,
|
||||
"container": true, "container_name": true, "endpoint": true,
|
||||
}
|
||||
|
||||
// incidentSignature identifies "the same problem" across incidents: the alert
|
||||
// name plus the stable group labels, sorted. Incidents in one team with equal
|
||||
// signatures are what the similar-incidents lookup returns. title stands in for
|
||||
// the name when the payload carried no alertname (groupless and dead man's
|
||||
// switch incidents).
|
||||
func incidentSignature(groupLabels map[string]string, title string) string {
|
||||
name := groupLabels["alertname"]
|
||||
if name == "" {
|
||||
name = title
|
||||
}
|
||||
rest := make([]string, 0, len(groupLabels))
|
||||
for k, v := range groupLabels {
|
||||
if k == "alertname" || volatileLabels[k] {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, k+"="+v)
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return name + "|" + strings.Join(rest, ",")
|
||||
}
|
||||
|
||||
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
||||
// leading with the alert name and appending whatever else the operator grouped
|
||||
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -237,6 +238,15 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
// The body is optional: clients that predate resolution notes send none.
|
||||
var req struct {
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil && err != io.EOF {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Resolution = strings.TrimSpace(req.Resolution)
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||
WHERE id = $3 AND resolved_at IS NULL`,
|
||||
@@ -252,6 +262,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if req.Resolution != "" {
|
||||
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
}
|
||||
}
|
||||
@@ -421,11 +437,17 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
// Pinned files the note as the resolution note: what fixed it.
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
noteType := evNote
|
||||
if req.Pinned {
|
||||
noteType = evResolutionNote
|
||||
}
|
||||
if req.Content == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("content is required"))
|
||||
return
|
||||
@@ -440,7 +462,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`, id, evNote, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -449,7 +471,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusCreated, models.IncidentEvent{
|
||||
ID: eventID,
|
||||
IncidentID: id,
|
||||
Type: evNote,
|
||||
Type: noteType,
|
||||
UserID: &user.ID,
|
||||
Username: &user.Username,
|
||||
Detail: &req.Content,
|
||||
@@ -475,8 +497,8 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
user, _ := userFromContext(r.Context())
|
||||
res, err := db.ExecContext(r.Context(), `
|
||||
DELETE FROM incident_events
|
||||
WHERE id = $1 AND incident_id = $2 AND type = $3 AND user_id = $4`,
|
||||
eventID, id, evNote, user.ID)
|
||||
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
|
||||
eventID, id, evNote, evResolutionNote, user.ID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
|
||||
@@ -330,6 +330,16 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err
|
||||
|
||||
msg := renderNotification(inc, n, firing, cfg)
|
||||
|
||||
// The page that opens an incident carries what fixed it last time, so the
|
||||
// person woken up starts from that. Best effort: a failed lookup must not
|
||||
// hold back the page itself.
|
||||
if n.kind == notifyTriggered {
|
||||
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
|
||||
notes := sim[0].ResolutionNotes
|
||||
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
|
||||
}
|
||||
}
|
||||
|
||||
// An Acknowledge button needs both a user to attribute the acknowledgement
|
||||
// to and a URL the phone can reach. Minted per delivery, so every push
|
||||
// carries its own short-lived token rather than reusing one.
|
||||
@@ -573,6 +583,17 @@ func plural(n int) string {
|
||||
return "s"
|
||||
}
|
||||
|
||||
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
|
||||
// a multi-line note stays one line in a push.
|
||||
func shorten(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
// derefString reads a nullable text column as a plain string.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
|
||||
@@ -74,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
// Your own account, or anybody's if you are an admin. The handlers call
|
||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||
// which rule applies depends on the {id} in the path.
|
||||
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
@@ -93,6 +94,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
// What exists on this server, and how it behaves. /api/teams
|
||||
// answers "what am I in"; this one answers "what is there".
|
||||
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
||||
// One team and who is in it. The member list under
|
||||
// /api/teams/{id}/members stays member-only and still 404s
|
||||
// an administrator from outside; this is a different
|
||||
// question, so it is a different endpoint.
|
||||
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
|
||||
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
||||
r.Put("/api/admin/settings", handleSetSettings(db))
|
||||
})
|
||||
@@ -106,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
||||
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
||||
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -226,6 +228,17 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// adminTeam is a team as an administrator sees it: what it is, plus how big it
|
||||
// is and how much is on fire in it. One definition, so a team in the list and a
|
||||
// team on its own page cannot describe themselves differently.
|
||||
type adminTeam struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
}
|
||||
|
||||
// handleAdminListTeams lists every team on the server, with its size. The
|
||||
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
||||
// which only an administrator may ask.
|
||||
@@ -244,13 +257,6 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type adminTeam struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
}
|
||||
teams := []adminTeam{}
|
||||
for rows.Next() {
|
||||
var t adminTeam
|
||||
@@ -270,6 +276,79 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminGetTeam answers "what is this team, and who is in it" for any team
|
||||
// on the server, which is the one question an administrator could not ask.
|
||||
//
|
||||
// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody
|
||||
// outside the team, administrator or not, and that stays exactly as it is:
|
||||
// member means membership and nothing else. Reading a team's shape is a
|
||||
// different thing from reading its work, so it gets an endpoint of its own
|
||||
// under AdminOnly rather than an exception carved into that rule. An
|
||||
// administrator still sees none of the team's incidents, alerts or rota.
|
||||
func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var t adminTeam
|
||||
var created int64
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
FROM teams t
|
||||
WHERE t.id = $1`, teamID).
|
||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
|
||||
// Same query and same ordering as handleListTeamMembers, so the two
|
||||
// answers to "who is in this team" cannot disagree about the answer.
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
||||
FROM team_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.team_id = $1
|
||||
ORDER BY u.username`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []models.TeamMember{}
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
var joined int64
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
members = append(members, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
// A wrapper rather than a team with the members hung off it: "members"
|
||||
// already means a count on the list endpoint, and one name must not be
|
||||
// a number in one answer and an array in the next.
|
||||
respond(w, http.StatusOK, map[string]any{"team": t, "members": members})
|
||||
}
|
||||
}
|
||||
|
||||
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
||||
// a team has nobody left to do it.
|
||||
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||
@@ -285,7 +364,15 @@ func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
|
||||
// Trimmed, as handleCreateTeam trims: without it " " is a team name
|
||||
// here but not at creation, which is one rule stated twice and only
|
||||
// half applied.
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
similarDefaultLimit = 5
|
||||
similarMaxLimit = 20
|
||||
)
|
||||
|
||||
// handleIncidentSimilar lists earlier, resolved incidents in the same team with
|
||||
// the same signature that someone left notes on, incidents with a resolution
|
||||
// note first. This is the "have we seen this before" answer for a responder
|
||||
// looking at a fresh incident; the plain notes are one timeline fetch away.
|
||||
func handleIncidentSimilar(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := similarDefaultLimit
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid limit"))
|
||||
return
|
||||
}
|
||||
limit = min(n, similarMaxLimit)
|
||||
}
|
||||
|
||||
out, err := similarIncidents(r.Context(), db, id, limit)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) {
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT o.id, o.title, o.triggered_at, o.resolved_at,
|
||||
(SELECT COUNT(*) FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type = $3)
|
||||
FROM incidents i
|
||||
JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature
|
||||
WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type IN ($3, $4))
|
||||
ORDER BY EXISTS (SELECT 1 FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type = $4) DESC,
|
||||
o.triggered_at DESC
|
||||
LIMIT $2`, id, limit, evNote, evResolutionNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.SimilarIncident{}
|
||||
ids := []int64{}
|
||||
for rows.Next() {
|
||||
var s models.SimilarIncident
|
||||
var triggered, resolved int64
|
||||
if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.TriggeredAt = time.Unix(triggered, 0).UTC()
|
||||
s.ResolvedAt = time.Unix(resolved, 0).UTC()
|
||||
s.ResolutionNotes = []models.IncidentEvent{}
|
||||
out = append(out, s)
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
nrows, err := q.QueryContext(ctx, `
|
||||
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at
|
||||
FROM incident_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
WHERE e.incident_id = ANY($1) AND e.type = $2
|
||||
ORDER BY e.created_at, e.id`, ids, evResolutionNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer nrows.Close()
|
||||
|
||||
byID := make(map[int64]*models.SimilarIncident, len(out))
|
||||
for i := range out {
|
||||
byID[out[i].ID] = &out[i]
|
||||
}
|
||||
for nrows.Next() {
|
||||
var e models.IncidentEvent
|
||||
var ts int64
|
||||
if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
s := byID[e.IncidentID]
|
||||
s.ResolutionNotes = append(s.ResolutionNotes, e)
|
||||
}
|
||||
return out, nrows.Err()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postGrouped posts a firing webhook whose group labels are exactly the given
|
||||
// map, unlike postWebhook, which only ever groups by alertname.
|
||||
func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) {
|
||||
t.Helper()
|
||||
labels := map[string]string{}
|
||||
for k, v := range groupLabels {
|
||||
labels[k] = v
|
||||
}
|
||||
payload := map[string]any{
|
||||
"version": "4", "status": "firing",
|
||||
"groupKey": fingerprint,
|
||||
"groupLabels": groupLabels,
|
||||
"alerts": []map[string]any{
|
||||
amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels),
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post webhook: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func similar(t *testing.T, s *ts, id int) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Same alert on another instance is the same problem; a resolution note left on
|
||||
// the first one is what the second one should be shown.
|
||||
func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve",
|
||||
map[string]string{"resolution": "rotated the logs"}).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"})
|
||||
|
||||
got := similar(t, s, 2)
|
||||
if len(got) != 1 || int(got[0]["id"].(float64)) != 1 {
|
||||
t.Fatalf("expected incident 1 as the only similar one, got %v", got)
|
||||
}
|
||||
notes := got[0]["resolution_notes"].([]any)
|
||||
if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" {
|
||||
t.Fatalf("expected the resolution note, got %v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A different stable label (job) is a different problem, and an incident nobody
|
||||
// wrote a note on has nothing to show.
|
||||
func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "node"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||
|
||||
// Incident 3 matches 2 by signature, but 2 has no notes.
|
||||
if got := similar(t, s, 3); len(got) != 0 {
|
||||
t.Fatalf("expected nothing similar to incident 3, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An open incident is not "earlier experience" yet, and the incident itself is
|
||||
// never its own match.
|
||||
func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||
map[string]any{"content": "still open", "pinned": true}).Body.Close()
|
||||
postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||
|
||||
if got := similar(t, s, 2); len(got) != 0 {
|
||||
t.Fatalf("expected an open incident not to be listed, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,68 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
||||
// "what is this person in", which /api/teams cannot answer because it is always
|
||||
// about the caller.
|
||||
//
|
||||
// Self or admin, matching the other per-user endpoints. It says which teams
|
||||
// somebody belongs to and in what role — not anything those teams own, so it
|
||||
// stays on the accounts side of the line the administrator flag draws.
|
||||
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, id) {
|
||||
return
|
||||
}
|
||||
|
||||
// A user with no teams and a user who does not exist both list nothing,
|
||||
// so the existence check is what tells them apart.
|
||||
var exists bool
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.name`, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
teams := []models.Team{}
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
teams = append(teams, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||
// with no owner would need an administrator to repair before anybody could use
|
||||
// it, so the two happen in one transaction.
|
||||
|
||||
+26
-4
@@ -5,6 +5,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +16,19 @@ import (
|
||||
//go:embed migrations
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// pingAttempts and pingRetryDelay bound the retry on the first connection.
|
||||
// This pod's own IP can reach the Postgres pod's node before that node's
|
||||
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
|
||||
// event) has added it to the allowed-source set, which fails the ping with
|
||||
// "connection refused" rather than a timeout. That race resolves within
|
||||
// several seconds in practice; five attempts two seconds apart give it
|
||||
// comfortable room without turning a genuinely absent database into a long
|
||||
// hang.
|
||||
const (
|
||||
pingAttempts = 5
|
||||
pingRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
|
||||
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
|
||||
//
|
||||
@@ -33,11 +47,19 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
err = db.Ping()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
if attempt == pingAttempts {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
|
||||
time.Sleep(pingRetryDelay)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate applies every embedded migration that has not been applied yet, in
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Similar incidents: a signature per incident, so "has this happened before"
|
||||
-- is an indexed equality instead of a search.
|
||||
--
|
||||
-- The signature is the alert name plus the group labels that identify WHAT is
|
||||
-- broken, minus the ones that only say WHERE it happened to run this time
|
||||
-- (instance, pod, ...). Two incidents with the same signature in the same team
|
||||
-- are the same problem for a responder's purposes.
|
||||
--
|
||||
-- Computed in Go for new incidents (incidentSignature in incident_store.go).
|
||||
-- The backfill below MUST produce the same string; keep the volatile list in
|
||||
-- both places in step.
|
||||
ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE incidents SET signature =
|
||||
COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' ||
|
||||
COALESCE((
|
||||
SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k)
|
||||
FROM jsonb_each_text(incidents.group_labels) AS e(k, v)
|
||||
WHERE e.k <> 'alertname'
|
||||
AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint')
|
||||
), '');
|
||||
|
||||
CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC);
|
||||
@@ -79,3 +79,15 @@ type IncidentEvent struct {
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SimilarIncident is an earlier, resolved incident with the same signature as
|
||||
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
|
||||
// NoteCount counts the plain working notes, which live on the timeline.
|
||||
type SimilarIncident struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
ResolvedAt time.Time `json:"resolved_at"`
|
||||
NoteCount int `json:"note_count"`
|
||||
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||
sub, err := fs.Sub(files, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, want := range map[string]string{
|
||||
"js/incident.js": "copyIncident",
|
||||
"js/ui.js": "copy:",
|
||||
} {
|
||||
b, err := fs.ReadFile(sub, file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), want) {
|
||||
t.Errorf("%s lacks %s", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+208
-22
@@ -31,6 +31,14 @@
|
||||
--snooze: #6b5bd2;
|
||||
--snooze-soft: #efedfb;
|
||||
|
||||
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||
tell six people apart and the palette above only has four that are not
|
||||
already an alarm. */
|
||||
--teal: #0f7d8c;
|
||||
--teal-soft: #e3f4f6;
|
||||
--pink: #b3427e;
|
||||
--pink-soft: #fbe8f2;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||
@@ -40,7 +48,11 @@
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
--topbar-h: 52px;
|
||||
--tabbar-h: 58px;
|
||||
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||
variable rather than deleted since .view, .toast and .nav's own height
|
||||
calc still read it. */
|
||||
--tabbar-h: 0px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
@@ -72,6 +84,11 @@
|
||||
--snooze: #a89bff;
|
||||
--snooze-soft: #262245;
|
||||
|
||||
--teal: #4fc2d4;
|
||||
--teal-soft: #0f2e33;
|
||||
--pink: #f07fb8;
|
||||
--pink-soft: #3a1c2d;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
@@ -183,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.topbar-title {
|
||||
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
#menu-btn { position: relative; }
|
||||
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
||||
|
||||
.open-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -196,15 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
|
||||
/* Bottom tab bar on phones. */
|
||||
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||
.nav {
|
||||
display: none;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
||||
there were four tabs; Team and Admin arriving pushed six items into four
|
||||
columns, which on a phone is how they stopped fitting. Auto columns mean
|
||||
the next tab cannot break the row either — and Admin is only rendered for
|
||||
an administrator, so the count genuinely varies between viewers. */
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
@@ -226,16 +246,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
||||
the widest label, "On-call", is about 38px at this size, so nothing has to
|
||||
be hidden. The ellipsis above is the backstop if a future tab is named
|
||||
something longer. */
|
||||
@media (max-width: 420px) {
|
||||
.nav-link { font-size: 10px; gap: 1px; }
|
||||
.nav-link svg { width: 21px; height: 21px; }
|
||||
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
||||
}
|
||||
|
||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||
@@ -367,6 +377,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-head .copy { margin-left: auto; }
|
||||
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
@@ -440,11 +452,16 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.tl-text { overflow-wrap: anywhere; }
|
||||
.tl-text .who { font-weight: 650; }
|
||||
.tl-time { color: var(--faint); font-size: 12px; }
|
||||
.tl-note .note {
|
||||
.note {
|
||||
margin-top: 6px; padding: 10px 12px;
|
||||
background: var(--surface-2); border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||
.similar { list-style: none; margin: 0; padding: 0; }
|
||||
.similar-item { padding: 10px 0; }
|
||||
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||
.note-actions { display: flex; justify-content: flex-end; }
|
||||
.note-actions .btn { color: var(--muted); }
|
||||
|
||||
@@ -588,8 +605,6 @@ kbd {
|
||||
/* ---------- desktop ---------- */
|
||||
|
||||
@media (min-width: 900px) {
|
||||
:root { --tabbar-h: 0px; }
|
||||
|
||||
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||
|
||||
.nav {
|
||||
@@ -625,6 +640,10 @@ kbd {
|
||||
.view-queue .pane { overflow: auto; height: 100dvh; }
|
||||
.pane-list { border-right: 1px solid var(--border); }
|
||||
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
||||
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||
.pane-list .chip-sep { display: none; }
|
||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||
|
||||
/* On desktop the list stays visible next to the detail. */
|
||||
@@ -680,6 +699,45 @@ kbd {
|
||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||
.small { font-size: 13px; }
|
||||
|
||||
/* A name in an admin table is the way to that row's own page -- a person's or
|
||||
a team's. */
|
||||
.row-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
||||
.row-link:hover { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||
/* The link is shown once and never stored, so it has to be selectable and
|
||||
wrap rather than scroll off the side of a phone. */
|
||||
.invite-out { margin-top: 12px; font-size: 13px; }
|
||||
.invite-link {
|
||||
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
|
||||
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
|
||||
word-break: break-all; user-select: all;
|
||||
}
|
||||
|
||||
/* --- one user, one team --------------------------------------------------
|
||||
Both subject pages share this: .user-head and .user-facts are generic
|
||||
despite the names, and a team fills them with its own facts. */
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.back-link svg { width: 18px; height: 18px; }
|
||||
|
||||
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.user-head h2 { margin: 0; }
|
||||
|
||||
.user-facts {
|
||||
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
|
||||
margin: 12px 0 0; font-size: 14px;
|
||||
}
|
||||
.user-facts dt { color: var(--muted); }
|
||||
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.admin-table .row-actions { margin-top: 0; gap: 6px; }
|
||||
|
||||
/* --- team settings -------------------------------------------------------
|
||||
Forms with a label above each control, rather than the queue's rows of
|
||||
links. The escalation ladder is the only nested structure in the app, so it
|
||||
@@ -690,6 +748,60 @@ kbd {
|
||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||
|
||||
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||
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
|
||||
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-wd {
|
||||
padding-bottom: 4px; text-align: center;
|
||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.rota-day {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
min-height: 52px; padding: 6px 0 8px;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
button.rota-day { cursor: pointer; }
|
||||
button.rota-day:hover { background: var(--surface-2); }
|
||||
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.rota-day.today { background: var(--accent-soft); }
|
||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||
.rota-day.past { opacity: 0.55; }
|
||||
/* The days either side of the month are real days and are drawn, but they
|
||||
belong to the month you are not looking at. */
|
||||
.rota-day.outside { opacity: 0.35; }
|
||||
|
||||
.rota-chip {
|
||||
display: grid; place-items: center;
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
font-size: 12px; font-weight: 750; text-transform: uppercase;
|
||||
}
|
||||
/* An empty day is a dot rather than a hole, and keeps the chip's box so the
|
||||
rows stay on one baseline. */
|
||||
.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); }
|
||||
|
||||
/* Six colours, then they repeat; the initial inside still tells two people
|
||||
apart. Deliberately not the severity palette — nothing here is critical. */
|
||||
.rc1 { background: var(--accent-soft); color: var(--accent); }
|
||||
.rc2 { background: var(--ok-soft); color: var(--ok); }
|
||||
.rc3 { background: var(--snooze-soft); color: var(--snooze); }
|
||||
.rc4 { background: var(--warn-soft); color: var(--warn); }
|
||||
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||
|
||||
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
||||
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
||||
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.rota-note:first-child { margin-top: 0; }
|
||||
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-bulk .stacked-form { margin-top: 4px; }
|
||||
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
|
||||
.ladder-level {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding: 8px 0 8px 12px; margin: 12px 0;
|
||||
@@ -729,3 +841,77 @@ kbd {
|
||||
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
|
||||
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
/* --- sub-navigation -------------------------------------------------------
|
||||
A strip of links across the top of every Admin and every Team page, one per
|
||||
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||
here and in the queue, and these go somewhere. Same aria-current convention
|
||||
as the tab bar, so the state lives on the attribute rather than in a class.
|
||||
|
||||
Six entries do not fit a phone's width, which is what the horizontal scroll
|
||||
below is for -- the Team tab's strip is the one that needs it. */
|
||||
.subnav {
|
||||
display: flex; gap: 2px;
|
||||
margin: 12px auto 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.subnav::-webkit-scrollbar { display: none; }
|
||||
.subnav-link {
|
||||
flex: none;
|
||||
padding: 8px 12px; margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.subnav-link:hover { color: var(--text); }
|
||||
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||
links to the sections, so these carry the counts, which is the part a menu
|
||||
cannot say. Not named for either tab: both use it, and the one that renamed
|
||||
.user-link to .row-link is the same rename for the same reason. */
|
||||
.overview-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||
.overview-menu .card + .card { margin-top: 0; }
|
||||
.overview-item { display: block; padding: 14px; }
|
||||
.overview-item:hover { background: var(--surface-hover); }
|
||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||
.overview-item p { margin: 4px 0 0; }
|
||||
|
||||
/* ---------- stats page ---------- */
|
||||
|
||||
#view-stats .chips { padding-left: 0; padding-right: 0; }
|
||||
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat-tile {
|
||||
--sev: var(--border-strong);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--sev); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 12px;
|
||||
}
|
||||
.stat-tile.st-triggered { --sev: var(--crit); }
|
||||
.stat-tile.st-acknowledged { --sev: var(--warn); }
|
||||
.stat-tile.st-resolved { --sev: var(--ok); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
|
||||
.chart-card + .chart-card { margin-top: 0; }
|
||||
.chart-title {
|
||||
margin-bottom: 10px; font-size: 13px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
|
||||
}
|
||||
.hbars { list-style: none; display: grid; gap: 6px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
|
||||
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
|
||||
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.columns { display: block; width: 100%; height: auto; }
|
||||
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
|
||||
.columns .col-hit { fill: transparent; }
|
||||
.columns .col-bar { fill: var(--accent); }
|
||||
.columns .col:hover .col-bar { opacity: 0.75; }
|
||||
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
|
||||
@media (min-width: 900px) {
|
||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||
<span class="nav-label">Alerts</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||
<span class="nav-label">Stats</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||
<span class="nav-label">Team</span>
|
||||
@@ -108,7 +112,13 @@
|
||||
</nav>
|
||||
|
||||
<header class="topbar">
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
<div class="topbar-left">
|
||||
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||
</button>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
<span class="open-pill" id="open-pill" hidden></span>
|
||||
</header>
|
||||
|
||||
@@ -122,8 +132,15 @@
|
||||
|
||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
|
||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||
list, and a section of its own so a deep link survives a reload. -->
|
||||
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
||||
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
|
||||
which the Team tab cannot show for a team you are not a member of. -->
|
||||
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ function render() {
|
||||
h('div', { class: 'account-name', text: user.username }),
|
||||
h('div', { class: 'account-email', text: user.email }))),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||
notifyForm(user),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||
passwordForm(user, hasPassword),
|
||||
|
||||
@@ -32,10 +35,90 @@ function render() {
|
||||
|
||||
h('div', { class: 'page-head' }),
|
||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
|
||||
);
|
||||
}
|
||||
|
||||
// Where this user's pages go. The onboarding checklist's first step sends
|
||||
// people here for it, and until now there was nothing here to send them to:
|
||||
// the topic could only be set with curl or by an administrator.
|
||||
//
|
||||
// The topic is the whole address — the server it is published to is the
|
||||
// install's one ntfy, set in the deployment and not something a user picks.
|
||||
function notifyForm(user) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
const topic = h('input', {
|
||||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||
autocapitalize: 'none', spellcheck: false,
|
||||
value: user.ntfy_topic || '',
|
||||
placeholder: 'terdut-a7f3c91e',
|
||||
});
|
||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||
|
||||
// Only offered once a topic is saved: the test publishes to whatever the
|
||||
// server has stored, not to whatever is half-typed in the field.
|
||||
const test = h('button', {
|
||||
class: 'btn', type: 'button', text: 'Send a test push',
|
||||
hidden: !user.ntfy_topic,
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
test.disabled = true;
|
||||
try {
|
||||
await api.testNotification();
|
||||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||||
ok.hidden = false;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
} finally {
|
||||
test.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const form = h('form', { class: 'card pw-form' },
|
||||
h('label', {},
|
||||
h('span', { text: 'ntfy topic' }),
|
||||
topic),
|
||||
h('p', { class: 'muted small' },
|
||||
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||||
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||||
'topic instead.'),
|
||||
// Worth saying plainly: people reach for their own name, and the topic is
|
||||
// the only thing standing between a stranger and their pages.
|
||||
h('p', { class: 'muted small' },
|
||||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||
'pick something unguessable rather than your name.'),
|
||||
err, ok,
|
||||
h('div', { class: 'row-actions' }, submit, test),
|
||||
);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||
// Keep the cached user in step, so the onboarding checklist stops
|
||||
// asking for this and the test button appears without a reload.
|
||||
state.me.user = updated;
|
||||
ok.textContent = updated.ntfy_topic
|
||||
? 'Topic saved.'
|
||||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||||
ok.hidden = false;
|
||||
test.hidden = !updated.ntfy_topic;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
function passwordForm(user, hasPassword) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
|
||||
+123
-63
@@ -1,23 +1,51 @@
|
||||
// Administration: the teams on this server, the people who can sign in, and
|
||||
// the settings that change how the server behaves.
|
||||
//
|
||||
// Each of those three is a route of its own, reached from a strip across the
|
||||
// top, with /admin itself an overview. They used to be three cards stacked on
|
||||
// one page, which meant no way to link to the settings, no way back to the top
|
||||
// of the user list but scrolling, and a poll that refetched all three endpoints
|
||||
// however little of the page you were looking at.
|
||||
//
|
||||
// Only rendered for a system administrator. The server enforces that on every
|
||||
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
||||
// permission — so this view simply says so rather than pretending to be a
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
|
||||
let data = null; // { teams, users, settings }
|
||||
// The sub-sections, in the order the strip shows them. The overview is /admin
|
||||
// itself, so it has no tab of its own. This table is the only place the four
|
||||
// routes are written down: app.js parses against it and the strip is built
|
||||
// from it, so adding a fifth is one line here.
|
||||
export const TABS = [
|
||||
{ tab: null, path: '/admin', label: 'Overview' },
|
||||
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
|
||||
{ tab: 'users', path: '/admin/users', label: 'Users' },
|
||||
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||
// loop calls refresh() with no route — the same reason adminuser.js keeps its
|
||||
// user ID in the module.
|
||||
let tab = null;
|
||||
let data = null; // whatever the current tab needs; the shape varies by tab
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
export function show() {
|
||||
if (!data) clear(view(), spinner());
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// A different sub-section wants different data, so the old answer goes
|
||||
// rather than being shown under the new heading until the fetch lands.
|
||||
if (next !== tab) {
|
||||
tab = next;
|
||||
data = null;
|
||||
}
|
||||
if (!data) clear(view(), subnav(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -28,12 +56,7 @@ export async function refresh() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [teams, users, settings] = await Promise.all([
|
||||
api.adminTeams(),
|
||||
api.users(),
|
||||
api.adminSettings(),
|
||||
]);
|
||||
data = { teams, users, settings };
|
||||
data = await load();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
@@ -41,6 +64,16 @@ export async function refresh() {
|
||||
render();
|
||||
}
|
||||
|
||||
// Only what the open sub-section shows. Users is the one that needs two: it
|
||||
// only points at Teams for an invite if there is a team to point at, and the
|
||||
// overview counts both.
|
||||
async function load() {
|
||||
if (tab === 'teams') return { teams: await api.adminTeams() };
|
||||
if (tab === 'settings') return { settings: await api.adminSettings() };
|
||||
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
|
||||
return { teams, users };
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(view(), h('div', { class: 'card' },
|
||||
@@ -48,14 +81,56 @@ function render() {
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
clear(view(),
|
||||
subnav(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
teamsCard(),
|
||||
usersCard(),
|
||||
settingsCard(),
|
||||
section(),
|
||||
);
|
||||
}
|
||||
|
||||
function section() {
|
||||
if (tab === 'teams') return teamsCard();
|
||||
if (tab === 'users') return usersCard();
|
||||
if (tab === 'settings') return settingsCard();
|
||||
return overview();
|
||||
}
|
||||
|
||||
// The strip across the top of every admin page. Ordinary links rather than
|
||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
|
||||
// /admin itself. The strip already links to the three, so this earns its place
|
||||
// by saying how much of each there is — the one thing a menu cannot.
|
||||
function overview() {
|
||||
const admins = data.users.filter((u) => u.is_admin).length;
|
||||
const disabled = data.users.filter((u) => u.disabled_at).length;
|
||||
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
|
||||
|
||||
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||
|
||||
return h('div', { class: 'overview-menu' },
|
||||
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||
open > 0
|
||||
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||
: 'Nothing open anywhere.'),
|
||||
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||
menuCard('/admin/settings', 'Settings', null,
|
||||
'How the server behaves, and where it is plugged in.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,37 +139,24 @@ function render() {
|
||||
function teamsCard() {
|
||||
const rows = data.teams.map((t) =>
|
||||
h('tr', {},
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
// The name is the way in: everything about one team lives on its own
|
||||
// page, and this table stays a list rather than becoming a form.
|
||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
|
||||
h('td', { class: 'num', text: String(t.members) }),
|
||||
h('td', { class: 'num', text: String(t.open_incidents) }),
|
||||
h('td', {},
|
||||
h('button', {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: 'Rename',
|
||||
onclick: () => renameTeam(t),
|
||||
}),
|
||||
// A team with open incidents cannot be deleted, and saying so before
|
||||
// the click is kinder than a 409 afterwards.
|
||||
h('button', {
|
||||
class: 'btn-sm danger',
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
disabled: t.open_incidents > 0,
|
||||
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
|
||||
onclick: () => deleteTeam(t),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Open a team for who is in it, the invites into it, and renaming or ',
|
||||
'deleting it. Deleting takes its alerts, incidents, schedule and ',
|
||||
'integrations with it, and is refused while anything is still open.'),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Name' }),
|
||||
h('th', { class: 'num', text: 'Members' }),
|
||||
h('th', { class: 'num', text: 'Open' }),
|
||||
h('th', { text: '' }))),
|
||||
h('th', { class: 'num', text: 'Open' }))),
|
||||
h('tbody', {}, rows)),
|
||||
newTeamForm(),
|
||||
);
|
||||
@@ -122,32 +184,6 @@ function newTeamForm() {
|
||||
return form;
|
||||
}
|
||||
|
||||
async function renameTeam(team) {
|
||||
const next = window.prompt(`Rename ${team.name} to:`, team.name);
|
||||
if (!next || next === team.name) return;
|
||||
try {
|
||||
await api.renameTeam(team.id, next);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function deleteTeam(team) {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${team.name}?`,
|
||||
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
|
||||
function usersCard() {
|
||||
@@ -155,7 +191,9 @@ function usersCard() {
|
||||
const self = u.id === myID();
|
||||
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||
h('td', {},
|
||||
h('strong', { text: u.username }),
|
||||
// The name is the way in: everything about one person lives on their
|
||||
// own page, and this table stays a list rather than becoming a form.
|
||||
h('a', { class: 'row-link', href: `/admin/users/${u.id}`, text: u.username }),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('td', { class: 'muted', text: u.email }),
|
||||
@@ -184,7 +222,8 @@ function usersCard() {
|
||||
h('h2', { text: 'Users' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||
'its acknowledgements and timeline entries. Deleting a user erases those.'),
|
||||
'its acknowledgements and timeline entries. Deleting a user erases those. ',
|
||||
'Open a name for their teams, their password and the rest.'),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'User' }),
|
||||
@@ -192,6 +231,27 @@ function usersCard() {
|
||||
h('th', { text: '' }),
|
||||
h('th', { text: '' }))),
|
||||
h('tbody', {}, rows)),
|
||||
invitePointer(),
|
||||
);
|
||||
}
|
||||
|
||||
// Adding a person is minting them an invite into a team, not creating a row:
|
||||
// whoever accepts it picks their own password, so one never passes through an
|
||||
// administrator, and the link carries the team, so they do not land on an empty
|
||||
// queue.
|
||||
//
|
||||
// The form for it lives on the team's own page. It always needed a team beside
|
||||
// it, and a picker here was the admission that an invite is a fact about a team
|
||||
// rather than about the server.
|
||||
function invitePointer() {
|
||||
return h('div', { class: 'invite-block' },
|
||||
h('h3', { text: 'Add someone' }),
|
||||
data.teams.length > 0
|
||||
? h('p', { class: 'muted small' },
|
||||
'Open the team you want them in, under ',
|
||||
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
|
||||
', and mint an invite there.')
|
||||
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
// One team, at /admin/teams/{id}: what it is, who is in it, the invites into
|
||||
// it, and the two destructive things an administrator can do to it.
|
||||
//
|
||||
// The mirror of adminuser.js. That page answers "which teams is this person
|
||||
// in"; this one answers "who is in this team" for a team the administrator
|
||||
// need not be a member of — which the Team tab cannot do, because it only
|
||||
// offers teams the viewer is in.
|
||||
//
|
||||
// Only rendered for a system administrator. The server enforces that on every
|
||||
// endpoint regardless, so this view says so rather than pretending to be a
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-adminteam');
|
||||
|
||||
let teamID = null;
|
||||
let data = null; // { team, members, users, invites }
|
||||
let error = null;
|
||||
let busy = false;
|
||||
// An invite link is shown once and never stored, so it lives here until the
|
||||
// page is left rather than being toasted away after three seconds.
|
||||
let freshInvite = null;
|
||||
|
||||
export function show(route) {
|
||||
const next = route && route.team != null ? route.team : null;
|
||||
if (next !== teamID) {
|
||||
teamID = next;
|
||||
data = null;
|
||||
error = null;
|
||||
freshInvite = null;
|
||||
}
|
||||
if (!data) clear(view(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (teamID == null || !state.me?.user?.is_admin) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The team and its members come from the admin endpoint in one answer:
|
||||
// /teams/{id}/members is member-only and 404s an administrator from
|
||||
// outside the team, deliberately. users() is the add-a-member picker.
|
||||
const [team, users, invites] = await Promise.all([
|
||||
api.adminTeam(teamID),
|
||||
api.users(),
|
||||
api.invites(teamID),
|
||||
]);
|
||||
data = { team: team.team, members: team.members, users, invites };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
// A team that is gone answers 404, where a missing user is simply absent
|
||||
// from a list adminuser.js already has. So the "no such team" state has to
|
||||
// be recognised here; left to the error banner it would read as a fetch
|
||||
// that failed, which is a different thing and invites a retry.
|
||||
if (err.status === 404) {
|
||||
data = { team: null, members: [], users: [], invites: [] };
|
||||
error = null;
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
if (!data.team) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
backLink(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
identityCard(),
|
||||
membersCard(),
|
||||
invitesCard(),
|
||||
dangerCard(),
|
||||
);
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
|
||||
function identityCard() {
|
||||
const t = data.team;
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
const name = h('input', {
|
||||
name: 'name', type: 'text', value: t.name, required: true,
|
||||
autocomplete: 'off', spellcheck: false,
|
||||
});
|
||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save name' });
|
||||
|
||||
// A field rather than the window.prompt this used to be. The server answers
|
||||
// 409 for a name already taken, and a dialog is the wrong place to read that.
|
||||
const form = h('form', { class: 'inline-form' }, name, submit);
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
const next = name.value.trim();
|
||||
if (!next || next === t.name) return;
|
||||
busy = true;
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await api.renameTeam(teamID, next);
|
||||
ok.textContent = 'Name saved.';
|
||||
ok.hidden = false;
|
||||
error = null;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
busy = false;
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
busy = false;
|
||||
submit.disabled = false;
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Created', when(t.created_at)),
|
||||
fact('Members', String(t.members)),
|
||||
fact('Open incidents', String(t.open_incidents)),
|
||||
),
|
||||
form, err, ok,
|
||||
);
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. So this card
|
||||
// edits rather than reporting what somebody else would have to do.
|
||||
function membersCard() {
|
||||
const rows = data.members.map((m) =>
|
||||
h('tr', {},
|
||||
// Unlike the Team tab's own member list, the name is a link: that
|
||||
// person's page is where the rest of them lives.
|
||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
||||
h('td', { class: 'muted small', text: m.role }),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
// The same endpoint both ways: adding is an upsert on the role.
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const inTeam = new Set(data.members.map((m) => m.user_id));
|
||||
// A disabled account cannot sign in, so putting one on a rota would be
|
||||
// staffing the team with somebody who cannot answer.
|
||||
const candidates = data.users.filter((u) => !inTeam.has(u.id) && !u.disabled_at);
|
||||
const pick = h('select', {},
|
||||
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Members' }),
|
||||
data.members.length === 0 && h('p', { class: 'muted small' },
|
||||
'Nobody is in this team. Its queue has no one to work it and its ',
|
||||
'escalation has no one to reach — add somebody, or delete it.'),
|
||||
data.members.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
candidates.length > 0 && form,
|
||||
);
|
||||
}
|
||||
|
||||
// --- invites ---------------------------------------------------------------
|
||||
|
||||
// Adding a person to the server is minting them an invite into a team, not
|
||||
// creating a row: whoever accepts it picks their own password, so one never
|
||||
// passes through an administrator, and the link carries the team, so they do
|
||||
// not land on an empty queue.
|
||||
//
|
||||
// This lives on the team rather than on the Users page, where it used to be
|
||||
// with a team picker beside it. The picker was the admission that an invite is
|
||||
// a fact about a team.
|
||||
function invitesCard() {
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const inv = await api.createInvite(teamID, role.value, 1);
|
||||
freshInvite = inv.url;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// The server lists spent and revoked invites too, and they are worth seeing:
|
||||
// "who was invited here" is part of the answer to "who is in this team".
|
||||
// Only a live one can be revoked, so only a live one offers the button.
|
||||
const rows = (data.invites || []).map((inv) => {
|
||||
const state = inviteState(inv);
|
||||
return h('tr', { class: state === 'live' ? '' : 'disabled-row' },
|
||||
h('td', {}, h('strong', { text: inv.role })),
|
||||
h('td', { class: 'muted small', text: `${inv.uses}/${inv.max_uses} used` }),
|
||||
h('td', { class: 'muted small', text: state === 'live' ? `expires ${when(inv.expires_at)}` : state }),
|
||||
h('td', { class: 'row-actions' },
|
||||
state === 'live' && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||
onclick: () => act(() => api.revokeInvite(teamID, inv.id)),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Invites' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'An invite link puts somebody in this team and lets them choose their ',
|
||||
'own password. It lasts a week and can be used once.'),
|
||||
rows.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
form,
|
||||
// Shown once and never stored, so it goes on the page to be copied.
|
||||
freshInvite && h('p', { class: 'invite-out' },
|
||||
h('strong', { text: 'Send them this link. It is shown once.' }),
|
||||
h('code', { class: 'invite-link', text: freshInvite })),
|
||||
);
|
||||
}
|
||||
|
||||
// Why a link no longer works, in the server's own order of precedence: revoked
|
||||
// beats spent beats expired. Only 'live' is still usable.
|
||||
function inviteState(inv) {
|
||||
if (inv.revoked) return 'revoked';
|
||||
if (inv.uses >= inv.max_uses) return 'used up';
|
||||
if (new Date(inv.expires_at).getTime() <= Date.now()) return 'expired';
|
||||
return 'live';
|
||||
}
|
||||
|
||||
// --- delete ----------------------------------------------------------------
|
||||
|
||||
function dangerCard() {
|
||||
const t = data.team;
|
||||
const blocked = t.open_incidents > 0;
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Delete' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Its alerts, incidents, schedule and integrations go with it. This ',
|
||||
'cannot be undone. Everybody in it keeps their account and stays in ',
|
||||
'whatever other teams they are in.'),
|
||||
h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: `Delete ${t.name}`,
|
||||
// Saying so before the click is kinder than a 409 afterwards.
|
||||
disabled: blocked,
|
||||
title: blocked ? 'Resolve its open incidents first' : '',
|
||||
onclick: deleteTeam,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteTeam() {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${data.team.name}?`,
|
||||
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteTeam(teamID);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
toast('Team deleted.');
|
||||
// Not act(): there is no longer a page here to refresh.
|
||||
navigate('/admin/teams');
|
||||
}
|
||||
|
||||
// --- plumbing --------------------------------------------------------------
|
||||
|
||||
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||
// 409 from the last-owner guard, and the one for a duplicate name, are the
|
||||
// server explaining itself, and the reader needs to see it.
|
||||
async function act(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// One person, at /admin/users/{id}: what they are, what they are in, and the
|
||||
// levers an administrator has over the account.
|
||||
//
|
||||
// A section of its own rather than an expanding row in the Admin tab's table,
|
||||
// because memberships and the account actions together are more than a row can
|
||||
// hold and still be read on a phone.
|
||||
//
|
||||
// Like the Admin tab, this hides nothing the server would allow and shows
|
||||
// nothing it would refuse: every write here is an endpoint that answers 403
|
||||
// without the flag, so the view is a description of the rules rather than an
|
||||
// enforcement of them.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-adminuser');
|
||||
|
||||
let userID = null;
|
||||
let data = null; // { user, teams, allTeams }
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
export function show(route) {
|
||||
const next = route && route.user != null ? route.user : null;
|
||||
if (next !== userID) {
|
||||
userID = next;
|
||||
data = null;
|
||||
error = null;
|
||||
}
|
||||
if (!data) clear(view(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (userID == null || !state.me?.user?.is_admin) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The user comes from the list rather than a show endpoint: there is no
|
||||
// GET /api/users/{id}, and adding one for a row the list already carries
|
||||
// would be a second way to say the same thing.
|
||||
const [users, teams, allTeams] = await Promise.all([
|
||||
api.users(),
|
||||
api.userTeams(userID),
|
||||
api.adminTeams(),
|
||||
]);
|
||||
const user = users.find((u) => u.id === userID) || null;
|
||||
data = { user, teams, allTeams };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
if (!data.user) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
backLink(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
identityCard(),
|
||||
teamsCard(),
|
||||
accountCard(),
|
||||
);
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
|
||||
function identityCard() {
|
||||
const u = data.user;
|
||||
const self = u.id === myID();
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'user-head' },
|
||||
h('h2', { text: u.username }),
|
||||
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Email', u.email),
|
||||
fact('Joined', when(u.created_at)),
|
||||
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
|
||||
u.disabled_at && fact('Disabled', when(u.disabled_at)),
|
||||
),
|
||||
// Both of these refuse your own account, and the last administrator's. An
|
||||
// enabled button that always fails is worse than no button.
|
||||
h('div', { class: 'row-actions' },
|
||||
!self && h('button', {
|
||||
class: 'btn', type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
onclick: () => setAdmin(!u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
class: 'btn', type: 'button',
|
||||
text: u.disabled_at ? 'Enable account' : 'Disable account',
|
||||
onclick: () => setDisabled(!u.disabled_at),
|
||||
}),
|
||||
),
|
||||
self && h('p', { class: 'muted small' },
|
||||
'You cannot change your own administrator flag or disable yourself — ',
|
||||
'that is how an install ends up with nobody who can administer it.'),
|
||||
);
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
|
||||
async function setAdmin(next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Make ${data.user.username} an administrator?`,
|
||||
text: 'They will be able to manage every account, configure any team, and grant this to others.',
|
||||
confirmLabel: 'Make admin',
|
||||
}))) return;
|
||||
await act(() => api.setUserAdmin(userID, next));
|
||||
}
|
||||
|
||||
async function setDisabled(next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Disable ${data.user.username}?`,
|
||||
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
|
||||
confirmLabel: 'Disable',
|
||||
danger: true,
|
||||
}))) return;
|
||||
await act(() => api.setUserDisabled(userID, next));
|
||||
}
|
||||
|
||||
// --- teams -----------------------------------------------------------------
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. So this card
|
||||
// edits, rather than reporting what somebody else would have to do.
|
||||
//
|
||||
// It is the one place membership can be changed from the person's side: the
|
||||
// Team tab asks "who is in this team", and answering "which teams is this
|
||||
// person in" there means visiting each team in turn.
|
||||
function teamsCard() {
|
||||
const rows = data.teams.map((t) =>
|
||||
h('tr', {},
|
||||
// Not a link: the Team tab always shows the viewer's own team, so
|
||||
// sending them there from somebody else's membership would be a lie.
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
h('td', { class: 'muted small', text: t.role }),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const inTeam = new Set(data.teams.map((t) => t.id));
|
||||
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
|
||||
const pick = h('select', {},
|
||||
...candidates.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
data.teams.length === 0 && h('p', { class: 'muted small' },
|
||||
'In no team. They can sign in, but there is no queue for them to work ',
|
||||
'and nothing to page them about.'),
|
||||
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
candidates.length > 0 && form,
|
||||
);
|
||||
}
|
||||
|
||||
// --- account ---------------------------------------------------------------
|
||||
|
||||
function accountCard() {
|
||||
const u = data.user;
|
||||
const self = u.id === myID();
|
||||
|
||||
const pw = h('input', {
|
||||
type: 'password', name: 'password', autocomplete: 'new-password',
|
||||
minlength: '10', required: true, placeholder: 'At least 10 characters',
|
||||
});
|
||||
const form = h('form', { class: 'inline-form' }, pw,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
// No current password: that check is for changing your own, and an
|
||||
// administrator setting somebody else's does not know it by design.
|
||||
await api.setPassword(userID, pw.value);
|
||||
pw.value = '';
|
||||
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Account' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Setting a password here is how somebody gets their first one, or a new ',
|
||||
'one after forgetting it. It signs them out everywhere else. They change ',
|
||||
'it themselves under Account afterwards.'),
|
||||
self ? h('p', { class: 'muted small' },
|
||||
'Change your own password under Account, where the current one is asked for.')
|
||||
: form,
|
||||
h('h3', { text: 'Delete' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Deleting erases their acknowledgements and timeline entries — incidents ',
|
||||
'they handled stop saying who did. Disabling keeps the history and is ',
|
||||
'almost always what is meant.'),
|
||||
h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
|
||||
disabled: self,
|
||||
title: self ? 'You cannot delete your own account' : '',
|
||||
onclick: deleteUser,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${data.user.username}?`,
|
||||
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteUser(userID);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
toast('User deleted.');
|
||||
navigate('/admin/users');
|
||||
}
|
||||
|
||||
// --- plumbing --------------------------------------------------------------
|
||||
|
||||
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||
// 409 from the last-owner or last-administrator guard is the server explaining
|
||||
// itself, and the reader needs to see it.
|
||||
async function act(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
@@ -67,6 +67,15 @@ export const setPassword = (userID, password, currentPassword) =>
|
||||
// users
|
||||
export const users = () => call('GET', '/users');
|
||||
|
||||
// What one person is in. /teams answers "what am I in" and cannot be asked
|
||||
// about anybody else, which is what the admin page's per-user view needs.
|
||||
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
||||
|
||||
// Where this user's pages go. An empty topic clears it, which the server
|
||||
// treats as "no topic of their own" rather than an error.
|
||||
export const setNotifyTarget = (id, ntfyTopic) =>
|
||||
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
|
||||
|
||||
// incidents
|
||||
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
||||
export const incident = (id) => call('GET', `/incidents/${id}`);
|
||||
@@ -74,15 +83,22 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
|
||||
|
||||
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
|
||||
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
|
||||
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
|
||||
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
|
||||
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
|
||||
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
|
||||
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
||||
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
||||
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
||||
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
||||
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
|
||||
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
|
||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||
|
||||
// stats
|
||||
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
|
||||
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
|
||||
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
|
||||
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
|
||||
|
||||
// alerts
|
||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||
|
||||
@@ -133,12 +149,17 @@ export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/sc
|
||||
// Administration. Every one of these is refused with 403 for anybody without
|
||||
// the flag, so the UI hides the section rather than guarding it.
|
||||
export const adminTeams = () => call('GET', '/admin/teams');
|
||||
// One team and who is in it: { team, members }. /teams/{id}/members is
|
||||
// member-only and answers 404 to an administrator from outside the team, which
|
||||
// is the rule rather than an oversight -- this asks the other question.
|
||||
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
|
||||
export const adminSettings = () => call('GET', '/admin/settings');
|
||||
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
||||
export const setUserAdmin = (id, isAdmin) =>
|
||||
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
||||
export const setUserDisabled = (id, disabled) =>
|
||||
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
||||
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
|
||||
export const schedule = (teamID, from, to) =>
|
||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||
|
||||
|
||||
@@ -8,30 +8,74 @@ import * as queue from './queue.js';
|
||||
import * as incident from './incident.js';
|
||||
import * as oncall from './oncall.js';
|
||||
import * as alerts from './alerts.js';
|
||||
import * as stats from './stats.js';
|
||||
import * as account from './account.js';
|
||||
import * as team from './team.js';
|
||||
import * as admin from './admin.js';
|
||||
import * as adminuser from './adminuser.js';
|
||||
import * as adminteam from './adminteam.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// One route per section; /incidents/{id} is the queue with a detail open.
|
||||
// One route per section; /incidents/{id} is the queue with a detail open, and
|
||||
// /admin/users/{id} is a section of its own rather than a mode of the Admin
|
||||
// tab, because it replaces the page rather than opening beside it.
|
||||
const SECTIONS = {
|
||||
queue: { title: 'Queue', view: queue },
|
||||
oncall: { title: 'On-call', view: oncall },
|
||||
alerts: { title: 'Alerts', view: alerts },
|
||||
stats: { title: 'Stats', view: stats },
|
||||
team: { title: 'Team', view: team },
|
||||
admin: { title: 'Admin', view: admin },
|
||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
||||
more: { title: 'Account', view: account },
|
||||
};
|
||||
|
||||
// The mobile hamburger menu's contents — the same sections the desktop
|
||||
// sidebar's .nav-link list carries in index.html, in the same order.
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
|
||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||
];
|
||||
|
||||
function parseRoute(pathname) {
|
||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
||||
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
||||
// Before the TABS lookup below, which matches a path exactly and would let
|
||||
// /admin/teams/7 fall through to the queue.
|
||||
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
||||
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
||||
const name = pathname.replace(/^\/|\/$/g, '');
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
||||
// The Admin and Team tabs' sub-sections are routes of their own. Each view
|
||||
// owns the table of its own, since each also builds the strip that links to
|
||||
// them; /team is in team.TABS as the overview, so it is matched here too.
|
||||
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||
if (t) return { section: 'admin', tab: t.tab };
|
||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||
if (tt) return { section: 'team', tab: tt.tab };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
|
||||
return { section: 'queue', incident: null };
|
||||
}
|
||||
|
||||
// What the top bar and the document title call this route. The sub-sections of
|
||||
// Admin and Team are pages in their own right, so they say which one rather
|
||||
// than the tab's name four or six times; either overview keeps the tab's own
|
||||
// name. A tab may carry a `title` where its strip label is too short to name a
|
||||
// page on its own.
|
||||
function title(r) {
|
||||
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
|
||||
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
|
||||
return t ? (t.title || t.label) : SECTIONS[r.section].title;
|
||||
}
|
||||
|
||||
let route = parseRoute(location.pathname);
|
||||
// How many in-app navigations deep we are, so Back can use the browser's
|
||||
// history when there is somewhere to go back to, and the queue otherwise.
|
||||
@@ -64,13 +108,16 @@ function render() {
|
||||
route = parseRoute(location.pathname);
|
||||
const app = $('app');
|
||||
|
||||
for (const [name, s] of Object.entries(SECTIONS)) {
|
||||
for (const name of Object.keys(SECTIONS)) {
|
||||
const el = $(`view-${name}`);
|
||||
el.hidden = name !== route.section;
|
||||
if (name === route.section) $('topbar-title').textContent = s.title;
|
||||
if (name === route.section) $('topbar-title').textContent = title(route);
|
||||
}
|
||||
// A section may light up somebody else's tab: /admin/users/{id} is still the
|
||||
// Admin tab as far as the nav is concerned, since there is no tab of its own.
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
for (const link of document.querySelectorAll('.nav-link')) {
|
||||
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
|
||||
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
|
||||
@@ -85,16 +132,49 @@ function render() {
|
||||
incident.show(route.incident);
|
||||
} else {
|
||||
incident.show(null);
|
||||
SECTIONS[route.section].view.show();
|
||||
SECTIONS[route.section].view.show(route);
|
||||
}
|
||||
|
||||
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
||||
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
||||
else if (prev.section !== route.section) window.scrollTo(0, 0);
|
||||
// A changed tab counts as a changed page: stepping from a long user list to
|
||||
// the settings should not land you halfway down them. So does a changed
|
||||
// subject — one team to the next is two pages, not one scrolled page.
|
||||
else if (prev.section !== route.section || prev.tab !== route.tab
|
||||
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
|
||||
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
// ---------- nav menu ----------
|
||||
|
||||
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
||||
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
||||
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
||||
function openNavMenu() {
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
||||
ui.openSheet(() => [
|
||||
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
||||
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||
ui.h('li', {}, ui.h('button', {
|
||||
class: 'menu-item',
|
||||
type: 'button',
|
||||
role: 'menuitemradio',
|
||||
'aria-checked': String(n.section === current),
|
||||
onclick: () => ui.closeSheet(n.path),
|
||||
},
|
||||
ui.icon(n.icon),
|
||||
n.label,
|
||||
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
||||
))),
|
||||
),
|
||||
]).then((path) => {
|
||||
if (path) navigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- refresh + badges ----------
|
||||
|
||||
async function refresh() {
|
||||
@@ -121,15 +201,18 @@ function updateBadges() {
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
const badge = document.querySelector('[data-badge]');
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
||||
// hamburger button (mobile) — only one of the two is ever visible at once.
|
||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||
const section = SECTIONS[route.section].title;
|
||||
const section = title(route);
|
||||
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
||||
document.title = triggered ? `(${triggered}) ${base}` : base;
|
||||
}
|
||||
@@ -143,6 +226,7 @@ async function boot() {
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
|
||||
@@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail');
|
||||
let currentID = null;
|
||||
let inc = null;
|
||||
let events = [];
|
||||
let similarList = [];
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
@@ -38,10 +39,15 @@ export async function refresh() {
|
||||
const id = currentID;
|
||||
if (id == null) return;
|
||||
try {
|
||||
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
|
||||
// Similar incidents are a courtesy: an older server answers 404 and a
|
||||
// failure here must not hide the incident itself.
|
||||
const [i, t, sim] = await Promise.all([
|
||||
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
||||
]);
|
||||
if (id !== currentID) return;
|
||||
inc = i;
|
||||
events = t;
|
||||
similarList = sim;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
if (id !== currentID) return;
|
||||
@@ -60,6 +66,8 @@ function render() {
|
||||
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
||||
icon('back')),
|
||||
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
||||
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
|
||||
icon('copy')),
|
||||
);
|
||||
|
||||
if (!inc) {
|
||||
@@ -81,6 +89,7 @@ function render() {
|
||||
facts(),
|
||||
groupLabels(),
|
||||
alertsSection(),
|
||||
similarSection(),
|
||||
timelineSection(),
|
||||
),
|
||||
actionBar(),
|
||||
@@ -179,8 +188,9 @@ function alertItem(a) {
|
||||
|
||||
// ---------- timeline ----------
|
||||
|
||||
function eventText(ev) {
|
||||
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
||||
// named spells users out instead of "you", for text that leaves this page.
|
||||
function eventText(ev, named = false) {
|
||||
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
|
||||
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
||||
const alertName = () => {
|
||||
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
||||
@@ -197,6 +207,7 @@ function eventText(ev) {
|
||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||
case 'note': return [strong(person), ' added a note'];
|
||||
case 'resolution_note': return [strong(person), ' noted what fixed it'];
|
||||
case 'notified': {
|
||||
const to = person ? strong(person) : 'the fallback topic';
|
||||
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
||||
@@ -209,6 +220,22 @@ function eventText(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
// Earlier incidents with the same signature that someone left notes on, the
|
||||
// ones that recorded what fixed it first. Plain notes are on that incident's
|
||||
// own page.
|
||||
function similarSection() {
|
||||
if (!similarList.length) return null;
|
||||
return h('section', { class: 'section' },
|
||||
h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })),
|
||||
h('div', { class: 'card' },
|
||||
h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' },
|
||||
h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }),
|
||||
h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }),
|
||||
...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })),
|
||||
)))),
|
||||
);
|
||||
}
|
||||
|
||||
function timelineSection() {
|
||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||
return h('section', { class: 'section' },
|
||||
@@ -223,20 +250,116 @@ function timelineSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
||||
|
||||
function timelineItem(ev) {
|
||||
const mine = ev.type === 'note' && ev.user_id === myID();
|
||||
const mine = isNote(ev) && ev.user_id === myID();
|
||||
return h('li', { class: `tl-item tl-${ev.type}` },
|
||||
h('span', { class: 'tl-dot' }),
|
||||
h('div', { class: 'tl-body' },
|
||||
h('div', { class: 'tl-text' }, eventText(ev)),
|
||||
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
||||
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
|
||||
isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : 'note', text: ev.detail || '' }),
|
||||
mine && h('div', { class: 'note-actions' },
|
||||
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- copy ----------
|
||||
|
||||
const fence = (rows) => ['```', ...rows, '```'];
|
||||
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
|
||||
|
||||
// incidentMarkdown is everything on this page as text that reads well in a chat
|
||||
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
|
||||
// it has been pasted somewhere else.
|
||||
function incidentMarkdown() {
|
||||
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
|
||||
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
|
||||
add('Status', inc.status);
|
||||
add('Severity', inc.severity);
|
||||
add('Team', inc.team_name);
|
||||
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
|
||||
add('Triggered', inc.triggered_at);
|
||||
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
|
||||
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
|
||||
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
|
||||
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
|
||||
if (inc.archived_at) add('Archived', inc.archived_at);
|
||||
const group = pairs(inc.group_labels);
|
||||
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
|
||||
|
||||
const alerts = inc.alerts || [];
|
||||
out.push('', `## Alerts (${alerts.length})`);
|
||||
for (const a of alerts) {
|
||||
out.push('', `### ${a.name} (${a.status})`);
|
||||
out.push(`- Started: ${a.starts_at}`);
|
||||
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
|
||||
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
|
||||
const labels = pairs(a.labels);
|
||||
if (labels.length) out.push('', 'Labels:', ...fence(labels));
|
||||
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
|
||||
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
|
||||
}
|
||||
|
||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||
if (sorted.length) {
|
||||
out.push('', '## Timeline', '');
|
||||
for (const ev of sorted) {
|
||||
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
|
||||
out.push(`- ${ev.created_at} ${text}`);
|
||||
if (isNote(ev) && ev.detail) {
|
||||
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
|
||||
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (similarList.length) {
|
||||
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
|
||||
for (const s of similarList) {
|
||||
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
|
||||
for (const n of s.resolution_notes || []) {
|
||||
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// writeClipboard falls back to execCommand: the async API needs a secure
|
||||
// context, and this server is often reached over plain HTTP.
|
||||
async function writeClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
|
||||
ta.value = text;
|
||||
document.body.append(ta);
|
||||
ta.select();
|
||||
try {
|
||||
if (!document.execCommand('copy')) throw new Error('copy refused');
|
||||
} finally {
|
||||
ta.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIncident() {
|
||||
if (!inc) return;
|
||||
try {
|
||||
await writeClipboard(incidentMarkdown());
|
||||
toast('Copied incident');
|
||||
} catch {
|
||||
toast('Could not copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- actions ----------
|
||||
|
||||
const isOpen = () => inc.status !== 'resolved';
|
||||
@@ -296,15 +419,35 @@ function unacknowledge() {
|
||||
|
||||
async function resolve() {
|
||||
const id = inc.id;
|
||||
const ok = await confirm({
|
||||
title: 'Resolve this incident?',
|
||||
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
||||
+ 'and if any are still firing this one stays closed regardless. '
|
||||
+ 'Use snooze if you only need it out of the way.',
|
||||
confirmLabel: 'Resolve',
|
||||
danger: true,
|
||||
const res = await openSheet(() => {
|
||||
const textarea = h('textarea', {
|
||||
name: 'resolution', autofocus: true, maxlength: '10000',
|
||||
placeholder: 'What fixed it? Optional, shown on the next similar incident.',
|
||||
});
|
||||
const form = h('form', {
|
||||
class: 'sheet-form',
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
closeSheet({ resolution: textarea.value.trim() });
|
||||
},
|
||||
},
|
||||
h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }),
|
||||
h('p', {
|
||||
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
||||
+ 'and if any are still firing this one stays closed regardless. '
|
||||
+ 'Use snooze if you only need it out of the way.',
|
||||
}),
|
||||
textarea,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||
h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })),
|
||||
);
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
|
||||
});
|
||||
return form;
|
||||
});
|
||||
if (ok) await run(() => api.resolve(id), 'Resolved');
|
||||
if (res) await run(() => api.resolve(id, res.resolution), 'Resolved');
|
||||
}
|
||||
|
||||
function archive() {
|
||||
@@ -382,16 +525,18 @@ async function addNote() {
|
||||
const textarea = h('textarea', {
|
||||
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
|
||||
});
|
||||
const fix = h('input', { type: 'checkbox', name: 'fix' });
|
||||
const form = h('form', {
|
||||
class: 'sheet-form',
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
const v = textarea.value.trim();
|
||||
if (v) closeSheet(v);
|
||||
if (v) closeSheet({ content: v, pinned: fix.checked });
|
||||
},
|
||||
},
|
||||
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
||||
textarea,
|
||||
h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
|
||||
@@ -402,7 +547,7 @@ async function addNote() {
|
||||
});
|
||||
return form;
|
||||
});
|
||||
if (content) await run(() => api.addNote(id, content), 'Note added');
|
||||
if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added');
|
||||
}
|
||||
|
||||
async function deleteNote(ev) {
|
||||
@@ -422,10 +567,12 @@ async function moreMenu() {
|
||||
items.push(item('user', 'Assign…', assign));
|
||||
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
||||
items.push(item('note', 'Add note…', addNote));
|
||||
items.push(item('copy', 'Copy incident', copyIncident));
|
||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||
} else {
|
||||
items.push(item('note', 'Add note…', addNote));
|
||||
items.push(item('copy', 'Copy incident', copyIncident));
|
||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
||||
}
|
||||
|
||||
@@ -454,6 +601,7 @@ export function key(e) {
|
||||
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
||||
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
||||
case 'c': addNote(); return true;
|
||||
case 'y': copyIncident(); return true;
|
||||
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Statistics: how many incidents, how fast they are answered, and when and
|
||||
// what the alerts are. The same figures the TUI's Stats tab shows, over a
|
||||
// range picked with the chips. The server scopes them to the caller's teams.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, emptyState, spinner } from './ui.js';
|
||||
import { duration } from './format.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// `days` counts back from today, inclusive; the server reads from/to as UTC
|
||||
// dates, so these are too.
|
||||
const RANGES = [
|
||||
{ id: 'today', label: 'Today', days: 1 },
|
||||
{ id: '7d', label: '7d', days: 7 },
|
||||
{ id: '30d', label: '30d', days: 30 },
|
||||
{ id: '90d', label: '90d', days: 90 },
|
||||
{ id: 'all', label: 'All', days: null },
|
||||
];
|
||||
|
||||
const view = () => document.getElementById('view-stats');
|
||||
|
||||
let range = '30d';
|
||||
let data = null;
|
||||
let error = null;
|
||||
|
||||
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||||
|
||||
function query(r) {
|
||||
if (!r.days) return {};
|
||||
const now = Date.now();
|
||||
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
||||
}
|
||||
|
||||
export function show() {
|
||||
render();
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const requested = range;
|
||||
const q = query(RANGES.find((x) => x.id === range));
|
||||
try {
|
||||
const [incidents, top, byHour, byDay] = await Promise.all([
|
||||
api.statsIncidents(q),
|
||||
api.statsTop({ ...q, limit: 10 }),
|
||||
api.statsByHour(q),
|
||||
api.statsByDay(q),
|
||||
]);
|
||||
if (requested !== range) return;
|
||||
data = { incidents, top, byHour, byDay };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
if (requested !== range) return;
|
||||
error = err.message;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function setRange(id) {
|
||||
if (id === range) return;
|
||||
range = id;
|
||||
data = null;
|
||||
render();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
|
||||
RANGES.map((r) => h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(r.id === range),
|
||||
onclick: () => setRange(r.id),
|
||||
text: r.label,
|
||||
})));
|
||||
|
||||
let body;
|
||||
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||
else if (!data) body = spinner();
|
||||
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
||||
body = emptyState('No data in this range', '', 'chart');
|
||||
} else {
|
||||
body = h('div', { class: 'stats' },
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
tiles(data.incidents),
|
||||
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
|
||||
card('Alerts by hour (UTC)', columns(
|
||||
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
|
||||
'Alerts by hour of day')),
|
||||
card('Alerts by day', columns(
|
||||
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
|
||||
'Alerts by day of week')));
|
||||
}
|
||||
clear(view(), h('div', {}, chips, body));
|
||||
}
|
||||
|
||||
// A missing mean means nothing has been acknowledged or resolved yet.
|
||||
const mean = (s) => (s == null ? '—' : duration(s * 1000));
|
||||
|
||||
function tiles(s) {
|
||||
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
|
||||
h('div', { class: 'stat-value', text: String(value) }),
|
||||
h('div', { class: 'stat-label', text: label }));
|
||||
return h('div', { class: 'stat-tiles' },
|
||||
tile('Incidents', s.total),
|
||||
tile('Triggered', s.triggered, 'st-triggered'),
|
||||
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
|
||||
tile('Resolved', s.resolved, 'st-resolved'),
|
||||
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
|
||||
tile('Mean time to resolve', mean(s.mttr_seconds)));
|
||||
}
|
||||
|
||||
function card(title, content) {
|
||||
return h('section', { class: 'chart-card card card-pad' },
|
||||
h('h3', { class: 'chart-title', text: title }), content);
|
||||
}
|
||||
|
||||
// Ranked names with a bar scaled to the busiest one.
|
||||
function topAlerts(items) {
|
||||
const max = Math.max(...items.map((x) => x.count), 1);
|
||||
return h('ol', { class: 'hbars' }, items.map((x) => {
|
||||
const fill = h('span', { class: 'hbar-fill' });
|
||||
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
|
||||
return h('li', { class: 'hbar' },
|
||||
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
|
||||
h('span', { class: 'hbar-track' }, fill),
|
||||
h('span', { class: 'hbar-count', text: String(x.count) }));
|
||||
}));
|
||||
}
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svg(tag, attrs = {}, text) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
if (text != null) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
// A column chart: one bar per item, the value in a tooltip, and a label under
|
||||
// the items marked `tick`.
|
||||
function columns(items, label) {
|
||||
const W = 480;
|
||||
const H = 140;
|
||||
const base = H - 18;
|
||||
const step = W / items.length;
|
||||
const max = Math.max(...items.map((x) => x.value), 1);
|
||||
const root = svg('svg', {
|
||||
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
|
||||
});
|
||||
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
|
||||
const x = i * step + step * 0.15;
|
||||
const g = svg('g', { class: 'col' });
|
||||
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
|
||||
// A full-height transparent hit area, so a tiny bar is still hoverable.
|
||||
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
|
||||
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
|
||||
root.appendChild(g);
|
||||
if (it.tick) {
|
||||
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
+342
-47
@@ -1,8 +1,16 @@
|
||||
// Team settings: the rota, who is in the team, where its alerts come from,
|
||||
// what it escalates through, and which of its alerts are heartbeats.
|
||||
// One team: the rota, who is in it, where its alerts come from, what it
|
||||
// escalates through, and which of its alerts are heartbeats.
|
||||
//
|
||||
// Everything here was API-only until now, which meant a team owner had to use
|
||||
// curl to set up escalation — the feature this whole line of work exists for.
|
||||
// Everything here was API-only until v0.12.0, which meant a team owner had to
|
||||
// use curl to set up escalation — the feature this whole line of work exists
|
||||
// for.
|
||||
//
|
||||
// Each of those five is a route of its own behind a strip across the top, with
|
||||
// /team an overview, the way 07914d5 split the Admin tab. The same reasons
|
||||
// applied here and more sharply: five cards on one page meant no way to link
|
||||
// somebody to the escalation ladder, no way to the switches but past a month
|
||||
// of rota, and a poll that refetched six endpoints however little of the page
|
||||
// you were looking at.
|
||||
//
|
||||
// The server decides what a role may do: an owner's edits succeed, a member's
|
||||
// are refused with 403, and a non-member gets 404 for the lot. This view hides
|
||||
@@ -10,19 +18,48 @@
|
||||
// than no form, but it is not the thing enforcing anything.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers } from './state.js';
|
||||
import { isoDate, addDays } from './format.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-team');
|
||||
|
||||
// The sub-sections, in the order the strip shows them. The overview is /team
|
||||
// itself, so it has no tab of its own. This table is the only place the six
|
||||
// routes are written down: app.js parses against it and the strip is built
|
||||
// from it, the same contract admin.js has.
|
||||
//
|
||||
// `label` is what the strip says and `title` what the top bar and the document
|
||||
// title say, where a strip label alone would be too thin to name a page —
|
||||
// "Sources · terdut" in a browser tab does not say sources of what.
|
||||
export const TABS = [
|
||||
{ tab: null, path: '/team', label: 'Overview' },
|
||||
{ tab: 'rota', path: '/team/rota', label: 'Rota', title: 'On-call rota' },
|
||||
{ tab: 'members', path: '/team/members', label: 'Members' },
|
||||
{ tab: 'escalation', path: '/team/escalation', label: 'Escalation' },
|
||||
{ tab: 'sources', path: '/team/sources', label: 'Sources', title: 'Alert sources' },
|
||||
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
|
||||
];
|
||||
|
||||
let teamID = null;
|
||||
let data = null; // { team, members, integrations, escalation, deadman, schedule, users }
|
||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||
// loop calls refresh() with no route.
|
||||
let tab = null;
|
||||
let data = null; // { team, ... }; which fields are present varies by tab
|
||||
let error = null;
|
||||
let freshKey = null; // an integration key, shown once, until the view is left
|
||||
|
||||
export function show() {
|
||||
if (!data) clear(view(), spinner());
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// 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
|
||||
// ladder draft goes with it: it is an edit of the page being left.
|
||||
if (next !== tab) {
|
||||
tab = next;
|
||||
data = null;
|
||||
draft = null;
|
||||
}
|
||||
if (!data) clear(view(), subnav(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -40,16 +77,7 @@ export async function refresh() {
|
||||
}
|
||||
teamID = team.id;
|
||||
try {
|
||||
// A member may read all of this; only the writes are owner-only.
|
||||
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
||||
api.teamMembers(team.id),
|
||||
api.integrations(team.id),
|
||||
api.escalation(team.id),
|
||||
api.deadman(team.id),
|
||||
api.schedule(team.id, isoDate(new Date()), isoDate(addDays(new Date(), 30))),
|
||||
allUsers(),
|
||||
]);
|
||||
data = { team, members, integrations, escalation, deadman, schedule, users };
|
||||
data = { team, ...(await load(team.id)) };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
@@ -57,6 +85,45 @@ export async function refresh() {
|
||||
render();
|
||||
}
|
||||
|
||||
// Only what the open sub-section shows. A member may read all of it; only the
|
||||
// writes are owner-only.
|
||||
//
|
||||
// Three of the five need the member list besides their own endpoint, and for
|
||||
// the same reason each time: a rota, a ladder target and a role are all a
|
||||
// person, and the page has to be able to name them. The overview is the one
|
||||
// that fetches everything, because saying how much of each there is means
|
||||
// asking each of them.
|
||||
async function load(id) {
|
||||
if (tab === 'rota') {
|
||||
const grid = gridDays();
|
||||
const [members, schedule] = await Promise.all([
|
||||
api.teamMembers(id),
|
||||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||
]);
|
||||
return { members, schedule };
|
||||
}
|
||||
if (tab === 'members') {
|
||||
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
||||
return { members, users };
|
||||
}
|
||||
if (tab === 'escalation') {
|
||||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||||
return { members, escalation };
|
||||
}
|
||||
if (tab === 'sources') return { integrations: await api.integrations(id) };
|
||||
if (tab === 'deadman') return { deadman: await api.deadman(id) };
|
||||
|
||||
const grid = gridDays();
|
||||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||||
api.teamMembers(id),
|
||||
api.integrations(id),
|
||||
api.escalation(id),
|
||||
api.deadman(id),
|
||||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||
]);
|
||||
return { members, integrations, escalation, deadman, schedule };
|
||||
}
|
||||
|
||||
function isOwner() {
|
||||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||||
}
|
||||
@@ -69,19 +136,43 @@ function render() {
|
||||
return;
|
||||
}
|
||||
clear(view(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
subnav(),
|
||||
teamPicker(),
|
||||
!isOwner() && h('div', { class: 'card' },
|
||||
// Said once on the overview rather than on all six pages: it explains why
|
||||
// the controls further down are missing, and a page of nothing but the
|
||||
// rota has no controls to explain.
|
||||
!isOwner() && tab === null && h('div', { class: 'card' },
|
||||
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||||
scheduleCard(),
|
||||
escalationCard(),
|
||||
integrationsCard(),
|
||||
deadmanCard(),
|
||||
membersCard(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
section(),
|
||||
);
|
||||
}
|
||||
|
||||
function section() {
|
||||
if (tab === 'rota') return scheduleCard();
|
||||
if (tab === 'members') return membersCard();
|
||||
if (tab === 'escalation') return escalationCard();
|
||||
if (tab === 'sources') return integrationsCard();
|
||||
if (tab === 'deadman') return deadmanCard();
|
||||
return overview();
|
||||
}
|
||||
|
||||
// The strip across the top of every team page. Ordinary links rather than
|
||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
}
|
||||
|
||||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||||
// It is above the sections rather than inside one because it changes the
|
||||
// subject of all six.
|
||||
function teamPicker() {
|
||||
if ((state.teams || []).length < 2) {
|
||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||
@@ -94,40 +185,244 @@ function teamPicker() {
|
||||
teamID = Number(select.value);
|
||||
data = null;
|
||||
freshKey = null;
|
||||
show();
|
||||
draft = null;
|
||||
refresh();
|
||||
});
|
||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
|
||||
// /team itself. The strip already links to the five, so this earns its place
|
||||
// the way /admin's does: by saying how much of each there is, which is the one
|
||||
// thing a menu cannot.
|
||||
function overview() {
|
||||
const today = isoDate(new Date());
|
||||
const onToday = (data.schedule || []).find((e) => e.date === today);
|
||||
const owners = (data.members || []).filter((m) => m.role === 'owner').length;
|
||||
const levels = (data.escalation?.levels || []).length;
|
||||
const keys = (data.integrations || []).length;
|
||||
const unused = (data.integrations || []).filter((i) => !i.last_used_at).length;
|
||||
const switches = (data.deadman?.matchers || '')
|
||||
.split(';').map((x) => x.trim()).filter(Boolean).length;
|
||||
|
||||
return h('div', { class: 'overview-menu' },
|
||||
menuCard('/team/rota', 'Rota', null,
|
||||
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||||
menuCard('/team/members', 'Members', (data.members || []).length,
|
||||
owners === 1 ? 'One owner.' : `${owners} owners.`),
|
||||
menuCard('/team/escalation', 'Escalation', levels || null,
|
||||
levels
|
||||
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
||||
: 'No ladder — nobody but the first person is woken.'),
|
||||
menuCard('/team/sources', 'Alert sources', keys || null,
|
||||
keys
|
||||
? (unused ? `${unused} of them never used.` : 'All in use.')
|
||||
: 'No key yet, so nothing can reach this team.'),
|
||||
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||||
switches ? 'Alerts whose absence opens an incident.' : 'Nothing watched.'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- schedule --------------------------------------------------------------
|
||||
|
||||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||||
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
||||
// more.
|
||||
function scheduleCard() {
|
||||
const rows = (data.schedule || []).map((e) =>
|
||||
h('tr', {},
|
||||
h('td', { text: e.date }),
|
||||
h('td', {}, h('strong', { text: e.username })),
|
||||
h('td', {}, isOwner() && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Clear',
|
||||
onclick: () => act(() => api.unassignSchedule(teamID, e.id)),
|
||||
})),
|
||||
));
|
||||
//
|
||||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||||
// which is a rota spelled out one day at a time: the question asked of it is
|
||||
// "who has which stretch", and thirty names down a column is the one shape
|
||||
// that answer cannot be read in. So each day carries a coloured initial
|
||||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||||
//
|
||||
// The same month laid out the same way as the on-call page's week, because it
|
||||
// is the same rota — heading and arrows outside the card, days inside it.
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'On-call rota' }),
|
||||
h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }),
|
||||
rows.length
|
||||
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
||||
: h('p', { class: 'muted', text: 'Nobody is scheduled.' }),
|
||||
isOwner() && assignForm(),
|
||||
);
|
||||
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||||
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
});
|
||||
|
||||
let monthStart = firstOfMonth(new Date());
|
||||
|
||||
function firstOfMonth(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
// The grid runs Monday to Sunday, so it starts before the 1st and ends after
|
||||
// the last. Both overhangs are fetched and drawn: a shift that begins on the
|
||||
// 30th is a fact about this month even though the days it runs into are not.
|
||||
function gridDays() {
|
||||
const start = mondayOf(monthStart);
|
||||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0);
|
||||
const span = Math.round((last - start) / 86400000) + 1;
|
||||
return { start, count: Math.ceil(span / 7) * 7 };
|
||||
}
|
||||
|
||||
function shiftMonth(n) {
|
||||
monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function scheduleCard() {
|
||||
const { start, count } = gridDays();
|
||||
const byDate = new Map((data.schedule || []).map((e) => [e.date, e]));
|
||||
const today = isoDate(new Date());
|
||||
const month = monthStart.getMonth();
|
||||
|
||||
// Whose colours to explain, in the order the month meets them. Only the days
|
||||
// of this month count: a name that appears solely in the overhang belongs to
|
||||
// the month next door and would be explaining a chip nobody asked about.
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = addDays(start, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
const inMonth = d.getMonth() === month;
|
||||
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||||
cells.push(dayCell(d, key, e, inMonth, today));
|
||||
}
|
||||
|
||||
const heads = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
// 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)) }));
|
||||
}
|
||||
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
h('h2', { text: 'On-call rota' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||||
}, icon('chevronLeft')),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost label', type: 'button',
|
||||
title: 'Back to this month',
|
||||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||
text: monthFmt.format(monthStart),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||||
}, icon('chevronRight')),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'rota-grid' }, heads, cells),
|
||||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||||
// The range form is the way to fill a whole shift at once, but it is not
|
||||
// what the page is for, so it stays folded away under the month it edits.
|
||||
isOwner() && h('details', { class: 'rota-bulk' },
|
||||
h('summary', { text: 'Assign a range of days' }),
|
||||
assignForm()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function dayCell(d, key, e, inMonth, today) {
|
||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||||
.filter(Boolean).join(' ');
|
||||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||||
const body = [
|
||||
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||||
e
|
||||
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||||
: h('span', { class: 'rota-chip none' }),
|
||||
];
|
||||
// A member sees the same grid without the affordance, the way every other
|
||||
// control on this page is hidden rather than shown and refused.
|
||||
return isOwner()
|
||||
? h('button', {
|
||||
class: cls, type: 'button', title: label, 'aria-label': label,
|
||||
onclick: () => daySheet(key, e),
|
||||
}, body)
|
||||
: h('div', { class: cls, title: label }, body);
|
||||
}
|
||||
|
||||
// A colour per person, taken from their place in the member list so that it
|
||||
// holds still as you page between months. Somebody who holds days but has
|
||||
// since left the team is not in that list and falls back to their id.
|
||||
function colorClass(userID) {
|
||||
const i = (data.members || []).findIndex((m) => m.user_id === userID);
|
||||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||||
}
|
||||
|
||||
function legend(seen) {
|
||||
if (!seen.size) return null;
|
||||
return h('div', { class: 'rota-legend' },
|
||||
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||||
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||||
h('span', { text: name }),
|
||||
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||||
)));
|
||||
}
|
||||
|
||||
// The gap count, which is the one thing the grid states only by omission. Days
|
||||
// already past are not counted: an empty Tuesday last week is history, not a
|
||||
// hole somebody still has to fill.
|
||||
function coverNote(byDate) {
|
||||
const today = isoDate(new Date());
|
||||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate();
|
||||
let gaps = 0;
|
||||
for (let day = 1; day <= last; day++) {
|
||||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||||
if (key >= today && !byDate.has(key)) gaps++;
|
||||
}
|
||||
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||||
return h('p', { class: 'rota-note' },
|
||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||
' left this month with nobody on call.');
|
||||
}
|
||||
|
||||
// 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
|
||||
// them, and the day you want to change is the one you just tapped.
|
||||
function daySheet(date, entry) {
|
||||
const who = memberSelect(entry ? entry.user_id : undefined);
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }),
|
||||
h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }),
|
||||
h('label', { class: 'sheet-pick' }, 'On call ', who),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
entry && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Clear',
|
||||
onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); },
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign',
|
||||
// replace, where the range form asks first: the sheet has just named
|
||||
// whoever holds the day, so taking it from them is the thing that was
|
||||
// asked for rather than something to be warned about.
|
||||
onclick: () => {
|
||||
closeSheet();
|
||||
act(() => api.assignSchedule(teamID, Number(who.value), [date], true));
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseISO(s) {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
function assignForm() {
|
||||
const who = memberSelect();
|
||||
const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) });
|
||||
// The form opens on the month above it rather than on today: it is folded
|
||||
// into that month's card, and paging to March to fill March and being handed
|
||||
// today's date would be the card and the form disagreeing about the subject.
|
||||
const now = new Date();
|
||||
const sameMonth = monthStart.getFullYear() === now.getFullYear()
|
||||
&& monthStart.getMonth() === now.getMonth();
|
||||
const from = h('input', {
|
||||
type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart),
|
||||
});
|
||||
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
||||
const replace = h('input', { type: 'checkbox' });
|
||||
|
||||
|
||||
@@ -34,15 +34,21 @@ export function clear(el, ...children) {
|
||||
const ICONS = {
|
||||
back: ['M15 18l-6-6 6-6'],
|
||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||||
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
|
||||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||||
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
|
||||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||
chevronRight: ['M9 6l6 6-6 6'],
|
||||
@@ -64,6 +70,14 @@ export function icon(name, cls = 'icon') {
|
||||
node.setAttribute('cx', cx);
|
||||
node.setAttribute('cy', cy);
|
||||
node.setAttribute('r', r);
|
||||
} else if (d.startsWith('rect:')) {
|
||||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||||
node = document.createElementNS(SVG, 'rect');
|
||||
node.setAttribute('x', x);
|
||||
node.setAttribute('y', y);
|
||||
node.setAttribute('width', w);
|
||||
node.setAttribute('height', hgt);
|
||||
if (rx) node.setAttribute('rx', rx);
|
||||
} else {
|
||||
node = document.createElementNS(SVG, 'path');
|
||||
node.setAttribute('d', d);
|
||||
@@ -160,6 +174,18 @@ export function labelChip(k, v) {
|
||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||||
}
|
||||
|
||||
// One entry in a section's overview: a card that is a link, carrying the count
|
||||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||||
export function menuCard(href, label, count, note) {
|
||||
return h('a', { class: 'card overview-item', href },
|
||||
h('div', { class: 'overview-head' },
|
||||
h('strong', { text: label }),
|
||||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||||
h('p', { class: 'muted small', text: note }),
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyState(title, text, iconName) {
|
||||
return h('div', { class: 'empty' },
|
||||
iconName && icon(iconName),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatsPageIsEmbedded(t *testing.T) {
|
||||
sub, err := fs.Sub(files, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
|
||||
if !strings.Contains(string(index), want) {
|
||||
t.Errorf("index.html lacks %s", want)
|
||||
}
|
||||
}
|
||||
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
|
||||
t.Errorf("js/stats.js not embedded: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user