Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 429d5fdda3 | |||
| 3cdd5aee1f | |||
| 6a03698f65 | |||
| 67d68ce058 | |||
| a6fa673e08 | |||
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 | |||
| 828cf87656 | |||
| ac9af8e4f5 | |||
| 8869ac864f | |||
| 0677e74cf8 | |||
| 56b8191a78 | |||
| 93761056eb | |||
| a92da7dcc0 | |||
| b39aac36b7 | |||
| 19f168ab7e | |||
| d827ceedff | |||
| 4e8c52c28c | |||
| fb927aa67b | |||
| d728af53b1 | |||
| 53e5e03f4e | |||
| 4d62c1130b | |||
| 3183e7e5c5 | |||
| 94d23a593c | |||
| b0a02c010b | |||
| 303e7a3365 | |||
| 7c87ae2af8 |
@@ -47,11 +47,11 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
|||||||
|
|
||||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
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
|
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||||
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
password and the ntfy topic your pages go to. 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.
|
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
|
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
|
side. Statistics remain in
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||||
|
|
||||||
You sign in with a username and password. Users have no password until one is
|
You sign in with a username and password. Users have no password until one is
|
||||||
@@ -85,6 +85,52 @@ How a browser stays signed in:
|
|||||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||||
the web UI (`/incidents/{id}`).
|
the web UI (`/incidents/{id}`).
|
||||||
|
|
||||||
|
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. 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
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -155,12 +201,25 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
Two kinds of setting, split by who changes them and how often.
|
||||||
|
|
||||||
|
**Where the server is plugged in** stays in the environment: the listen address,
|
||||||
|
the database DSN, the ntfy URL and token, the public URL. They are needed before
|
||||||
|
the database is open, and two of them are credentials.
|
||||||
|
|
||||||
|
**How the server behaves** lives in the database and is edited by an
|
||||||
|
administrator in the web UI or through `PUT /api/admin/settings`, taking effect
|
||||||
|
on the next sweep rather than at the next restart. The variables below marked
|
||||||
|
**seed** are the value each of those starts from: written once, on first start,
|
||||||
|
and never overwritten afterwards — a redeploy cannot put a chart's default back
|
||||||
|
over an administrator's edit.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
|
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
|
||||||
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
|
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
|
||||||
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | How long a resolved alert or incident stays in the default list before being auto-archived |
|
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | **seed.** How long a resolved alert or incident stays in the default list before being auto-archived |
|
||||||
| `TERDUT_STALE_AFTER` | `6h` | How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
|
| `TERDUT_STALE_AFTER` | `6h` | **seed.** How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
|
||||||
| `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | The **default** matchers a team starts with — switches are per team now, and this seeds teams that have no configuration of their own. `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
|
| `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | The **default** matchers a team starts with — switches are per team now, and this seeds teams that have no configuration of their own. `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
|
||||||
| `TERDUT_DEADMAN_TIMEOUT` | `15m` | How long a heartbeat may go unheard before its switch is declared dead — **must be shorter than the `repeat_interval` of the route carrying it**. `0` disables dead man's switch handling |
|
| `TERDUT_DEADMAN_TIMEOUT` | `15m` | How long a heartbeat may go unheard before its switch is declared dead — **must be shorter than the `repeat_interval` of the route carrying it**. `0` disables dead man's switch handling |
|
||||||
| `TERDUT_DEADMAN_SEVERITY` | `critical` | Severity a dead man's switch incident opens at |
|
| `TERDUT_DEADMAN_SEVERITY` | `critical` | Severity a dead man's switch incident opens at |
|
||||||
@@ -168,7 +227,7 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g
|
|||||||
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
|
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
|
||||||
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
||||||
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
|
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
|
||||||
| `TERDUT_NOTIFY_REPEAT` | `15m` | How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
||||||
|
|
||||||
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
||||||
|
|
||||||
@@ -182,19 +241,39 @@ In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and
|
|||||||
|
|
||||||
## Alertmanager configuration
|
## Alertmanager configuration
|
||||||
|
|
||||||
Add terdut-server as a webhook receiver in your `alertmanager.yml`:
|
Alerts arrive on a team's **integration key**, which says both that the sender
|
||||||
|
may post and which team the alerts belong to. Mint one as an owner of the team:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://terdut.example.com/api/teams/1/integrations \
|
||||||
|
-H "Authorization: Bearer $TERDUT_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"prod alertmanager"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response carries the key and the full URL **once**; only a SHA-256 hash is
|
||||||
|
stored. Put it in your `alertmanager.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
receivers:
|
receivers:
|
||||||
- name: terdut
|
- name: terdut
|
||||||
webhook_configs:
|
webhook_configs:
|
||||||
- url: http://terdut-server:8080/api/alertmanager/webhook
|
- url: http://terdut-server:8080/api/integrations/<key>/alertmanager
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
|
|
||||||
route:
|
route:
|
||||||
receiver: terdut
|
receiver: terdut
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The whole URL is a credential, so treat it like one. Alertmanager 0.26 and
|
||||||
|
later can read it from a file with `url_file:` instead, which keeps it out of
|
||||||
|
your configuration repository:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- url_file: /etc/alertmanager/secrets/terdut-webhook-url/url
|
||||||
|
send_resolved: true
|
||||||
|
```
|
||||||
|
|
||||||
The webhook endpoint requires no authentication.
|
The webhook endpoint requires no authentication.
|
||||||
|
|
||||||
If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give
|
If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give
|
||||||
@@ -296,10 +375,20 @@ exactly as it was rather than with a hole in it.
|
|||||||
### Push notifications
|
### Push notifications
|
||||||
|
|
||||||
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
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
|
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
|
||||||
`PUT /api/users/{id}/notify`; a user with no topic falls back to
|
under *Account* in the web UI, where a **Send a test push** button proves it
|
||||||
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
|
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
|
||||||
If neither yields a topic, nothing is queued.
|
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:
|
Three things get pushed:
|
||||||
|
|
||||||
@@ -344,6 +433,50 @@ exhausts its retries. Written from the result rather than at enqueue, so the
|
|||||||
timeline says what actually happened — and a page that never landed is visible
|
timeline says what actually happened — and a page that never landed is visible
|
||||||
instead of looking the same as one that did.
|
instead of looking the same as one that did.
|
||||||
|
|
||||||
|
### Escalation
|
||||||
|
|
||||||
|
Without a ladder, an unacknowledged incident re-pages the same topic every
|
||||||
|
`notify_repeat` forever. That is a louder version of the same silence: if the
|
||||||
|
person on call is asleep, out of signal, or has left, nothing else happens.
|
||||||
|
|
||||||
|
A team can configure an ordered ladder instead. Each level has a timeout and a
|
||||||
|
set of targets, and a target is either a named person or **whoever the team's
|
||||||
|
rota says is on call today** — the target that keeps working when the rota
|
||||||
|
changes and nobody remembers to edit the policy.
|
||||||
|
|
||||||
|
```
|
||||||
|
level 1 5m oncall the rota gets first refusal
|
||||||
|
level 2 5m user:bob then a named second
|
||||||
|
then repeat_count more rounds
|
||||||
|
then the team's fallback topic, once
|
||||||
|
```
|
||||||
|
|
||||||
|
When a level's timeout passes with the incident still `triggered`, the next
|
||||||
|
level is paged. Off the end of the ladder the whole thing runs again
|
||||||
|
`repeat_count` times, and after that the team's `fallback_topic` is paged once
|
||||||
|
as the end of the line. The incident stays open throughout: running out of
|
||||||
|
people to wake is not the same as somebody answering.
|
||||||
|
|
||||||
|
**Acknowledging or resolving stops it**, which is the point — continuing to wake
|
||||||
|
people after somebody has said "I have this" is how a tool teaches people to
|
||||||
|
mute it. **Snoozing pauses it**: a deliberate "not now" holds the ladder where
|
||||||
|
it is, and it resumes when the snooze runs out.
|
||||||
|
|
||||||
|
Every step is on the incident's timeline with the level and the names it woke,
|
||||||
|
so somebody reading it afterwards can tell why their phone rang at 04:00. A
|
||||||
|
level whose targets are all unreachable — no ntfy topic, a disabled account, an
|
||||||
|
empty rota — is recorded as `nobody reachable` and the ladder moves on rather
|
||||||
|
than stalling on a rung that cannot ring.
|
||||||
|
|
||||||
|
**Reminders and escalation never both run.** A team with a ladder gets
|
||||||
|
escalation; a team without keeps the reminder behaviour exactly as it was. Two
|
||||||
|
pages for one silence is the surest way to get a tool muted.
|
||||||
|
|
||||||
|
The ladder's `fallback_topic` is per team, unlike `TERDUT_NTFY_FALLBACK_TOPIC`,
|
||||||
|
which is the install-wide topic used when an incident opens with nobody on call.
|
||||||
|
They answer different questions: one is "nobody was scheduled", the other is
|
||||||
|
"everybody scheduled has been tried".
|
||||||
|
|
||||||
### Stale alert expiry
|
### Stale alert expiry
|
||||||
|
|
||||||
A resolved webhook is the only signal that an alert has stopped firing, so a
|
A resolved webhook is the only signal that an alert has stopped firing, so a
|
||||||
@@ -447,7 +580,7 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
All endpoints except `/api/bootstrap`, `/api/alertmanager/webhook`,
|
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
|
||||||
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
|
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -463,6 +596,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
|
|||||||
assigning, snoozing, resolving, noting — and manages their own account and
|
assigning, snoozing, resolving, noting — and manages their own account and
|
||||||
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
||||||
|
|
||||||
|
**Getting an account.** The first one comes from `/api/bootstrap`. After that
|
||||||
|
it depends on `signup_mode`, an administrator setting:
|
||||||
|
|
||||||
|
- `invite_only` (the default) — a team owner mints a link with
|
||||||
|
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
|
||||||
|
username and password and lands in that team with the role the link carries.
|
||||||
|
Links are single-use unless told otherwise, expire after seven days, and can
|
||||||
|
be revoked before that.
|
||||||
|
- `open` — anybody who can reach the server can create an account, and must
|
||||||
|
name a team, which they then own.
|
||||||
|
|
||||||
|
Invites are **links, not email**: this server has no SMTP, and adding it to send
|
||||||
|
one message would be a subsystem to run, secure and monitor. Send the link
|
||||||
|
however you already talk to the person.
|
||||||
|
|
||||||
|
A domain-restricted third mode was considered and dropped: with no email there
|
||||||
|
is nothing to verify an address against, so it would only check the domain of a
|
||||||
|
string somebody typed.
|
||||||
|
|
||||||
The first user, from `/api/bootstrap`, is an administrator. Users created
|
The first user, from `/api/bootstrap`, is an administrator. Users created
|
||||||
afterwards are not, until an administrator says so. An install always keeps at
|
afterwards are not, until an administrator says so. An install always keeps at
|
||||||
least one: the last administrator can be neither deleted nor demoted, and
|
least one: the last administrator can be neither deleted nor demoted, and
|
||||||
@@ -473,10 +625,16 @@ Endpoints that require the flag answer `403` with
|
|||||||
|
|
||||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
**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
|
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
|
sees exactly the teams they belong to. Within a team an **owner** configures it
|
||||||
every team, because administration is about accounts, not about reading other
|
(schedule, integrations, membership) and a **member** works its incidents.
|
||||||
people's incidents. 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
|
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.
|
incident exists is itself something only its team should learn.
|
||||||
@@ -497,16 +655,29 @@ on anybody's.
|
|||||||
|
|
||||||
| Method | Path | Who | Description |
|
| Method | Path | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
|
||||||
|
| `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 |
|
| `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` | 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 |
|
| `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 |
|
| `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 |
|
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||||
|
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
|
||||||
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
||||||
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
||||||
| `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once |
|
| `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once |
|
||||||
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key |
|
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key |
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
### Alert ingestion
|
### Alert ingestion
|
||||||
|
|
||||||
Alerts arrive on a team's integration key. The key is both the credential and the
|
Alerts arrive on a team's integration key. The key is both the credential and the
|
||||||
@@ -517,17 +688,23 @@ and the full URL once and stores only a SHA-256 hash.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key |
|
| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key |
|
||||||
| `POST` | `/api/alertmanager/webhook` | **Deprecated, unauthenticated.** The pre-teams receiver, kept for one release so an upgrade does not stop delivering while the Alertmanager config is edited. Routes everything to the oldest team |
|
|
||||||
|
|
||||||
The deprecated path is why anything that can reach the port can still open an
|
This is the only way in. The pre-teams `POST /api/alertmanager/webhook` took no
|
||||||
incident. Move senders to a key and it goes away.
|
credential at all — anything able to reach the port could open an incident —
|
||||||
|
and was removed in v0.13.0 once senders had moved onto keys.
|
||||||
|
|
||||||
### Teams
|
### 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 |
|
| Method | Path | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||||
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
||||||
|
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
|
||||||
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
||||||
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
|
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
|
||||||
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
||||||
@@ -535,6 +712,11 @@ incident. Move senders to a key and it goes away.
|
|||||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
||||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
||||||
|
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
|
||||||
|
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
|
||||||
|
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
|
||||||
|
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
||||||
|
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
||||||
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
||||||
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent |
|
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent |
|
||||||
|
|
||||||
@@ -765,10 +947,11 @@ name on them.
|
|||||||
|
|
||||||
What changes, and will need attention:
|
What changes, and will need attention:
|
||||||
|
|
||||||
- **Alert ingestion moved.** `POST /api/alertmanager/webhook` still works but is
|
- **Alert ingestion moved.** Mint a key with
|
||||||
deprecated and unauthenticated, and routes everything to the oldest team. Mint
|
`POST /api/teams/{teamID}/integrations` and point Alertmanager at the URL it
|
||||||
a key with `POST /api/teams/{teamID}/integrations` and point Alertmanager at
|
returns. In v0.12.0 the old `POST /api/alertmanager/webhook` still worked,
|
||||||
the URL it returns. The old path goes away in a later release.
|
deprecated, routing everything to the oldest team; **v0.13.0 removes it**, so
|
||||||
|
upgrade straight from v0.11.x to v0.13.0 only after the senders are moved.
|
||||||
- **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and
|
- **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and
|
||||||
editing the rota is now an owner's job. `GET /api/schedule/current` stayed
|
editing the rota is now an owner's job. `GET /api/schedule/current` stayed
|
||||||
where it was but now returns an **array** — one entry per team with somebody
|
where it was but now returns an **array** — one entry per team with somebody
|
||||||
@@ -810,30 +993,25 @@ non-administrator now gets `403` where a `200` used to come back.
|
|||||||
|
|
||||||
## Upgrading from SQLite
|
## Upgrading from SQLite
|
||||||
|
|
||||||
Versions up to v0.10.2 stored everything in a SQLite file. From the Postgres release onwards
|
Versions up to v0.10.2 stored everything in a SQLite file. From v0.11.1 the server needs
|
||||||
the server needs `TERDUT_DB_DSN` and keeps nothing on disk.
|
`TERDUT_DB_DSN` and keeps nothing on disk.
|
||||||
|
|
||||||
The cutover is ordered — the server must not be running while the copy happens:
|
The copy was done by `scripts/sqlite-to-postgres.go`, which **was deleted in v0.13.0** along
|
||||||
|
with the SQLite driver it was the last user of. It is still in the history — check out the
|
||||||
|
`v0.12.0` tag to get it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Stop the old server, keeping its database file.
|
git show v0.12.0:scripts/sqlite-to-postgres.go > sqlite-to-postgres.go
|
||||||
# 2. Create an empty Postgres database, then let the new binary build the schema:
|
|
||||||
TERDUT_DB_DSN='postgres://terdut:secret@localhost:5432/terdut?sslmode=disable' ./terdut &
|
|
||||||
# ...watch for "listening on", then stop it again.
|
|
||||||
# 3. Copy the data across:
|
|
||||||
go run -tags migrate ./scripts/sqlite-to-postgres.go \
|
|
||||||
-sqlite /data/terdut.db \
|
|
||||||
-dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
|
|
||||||
# 4. Start the new server for good.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The cutover is ordered, and the server must not be running while the copy happens: stop the
|
||||||
|
old version, let the new binary build the schema against an empty Postgres, run the script
|
||||||
|
with `-sqlite` and `-dsn`, then start the new version for good. On Kubernetes step three runs
|
||||||
|
as a Job with the same image against the PVC before it is removed.
|
||||||
|
|
||||||
The copy preserves every id, so incidents keep their numbers and the timeline, alert
|
The copy preserves every id, so incidents keep their numbers and the timeline, alert
|
||||||
membership, outbox and ack tokens all still point where they did. It refuses a target that
|
membership, outbox and ack tokens all still point where they did. It refuses a target that
|
||||||
already has rows, so a second run cannot double-insert. On Kubernetes, step 3 runs as a Job
|
already has rows, so a second run cannot double-insert.
|
||||||
with the same image against the PVC before it is removed.
|
|
||||||
|
|
||||||
The script is deliberately temporary: it is the only thing left that needs the SQLite driver,
|
|
||||||
and both should be deleted once the installs that need them have migrated.
|
|
||||||
|
|
||||||
## Upgrading to incidents
|
## Upgrading to incidents
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.12.0
|
version: 0.19.0
|
||||||
appVersion: "v0.12.0"
|
appVersion: "v0.19.0"
|
||||||
|
|||||||
+7
-1
@@ -45,7 +45,13 @@ func main() {
|
|||||||
log.Fatalf("seed dead man's switch defaults: %v", err)
|
log.Fatalf("seed dead man's switch defaults: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := api.NewRouter(database, notify)
|
// The behaviour knobs move into the database on first start, after which an
|
||||||
|
// administrator owns them and a redeploy leaves them alone.
|
||||||
|
if err := api.SeedSettings(context.Background(), database, cfg); err != nil {
|
||||||
|
log.Fatalf("seed settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := api.NewRouter(database, notify, cfg)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
|
|||||||
@@ -7,22 +7,12 @@ require (
|
|||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
modernc.org/sqlite v1.50.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -21,14 +13,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
|||||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -36,46 +22,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
|
||||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
|
||||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
@@ -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.
|
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
@@ -94,27 +94,6 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLegacyWebhook is the pre-teams unauthenticated endpoint, kept for one
|
|
||||||
// release so an upgrade does not silently stop delivering while somebody edits
|
|
||||||
// the Alertmanager config. It routes to the oldest team, which on an upgraded
|
|
||||||
// install is the Default team everything was moved into.
|
|
||||||
//
|
|
||||||
// It is deprecated and unauthenticated — anything that can reach the port can
|
|
||||||
// open an incident. Move senders to an integration key and this goes away.
|
|
||||||
func handleLegacyWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
teamID, err := defaultTeamID(r.Context(), db)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("legacy webhook: no team to route to: %v", err)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("legacy webhook: unauthenticated payload routed to team %d; "+
|
|
||||||
"move this sender to an integration key", teamID)
|
|
||||||
receiveWebhook(w, r, db, notify, teamID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
|
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
|
||||||
var payload amPayload
|
var payload amPayload
|
||||||
if err := decodeJSON(r, &payload); err != nil {
|
if err := decodeJSON(r, &payload); err != nil {
|
||||||
@@ -424,12 +403,18 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue the page, but do not send it here: this runs inside a transaction on
|
// Queue the page, but do not send it here: this runs inside the webhook's
|
||||||
// a single-connection pool, so an HTTP call would hold up every other
|
// transaction, and an HTTP call would hold a connection open across a
|
||||||
// request. The notifier picks the row up within a tick.
|
// network round trip. The notifier picks the row up within a tick.
|
||||||
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
|
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And start the escalation clock, if the team keeps one. In the same
|
||||||
|
// transaction, so an incident is never briefly open with nobody counting.
|
||||||
|
if err := startEscalation(ctx, q, id, teamID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import (
|
|||||||
type ts struct {
|
type ts struct {
|
||||||
*httptest.Server
|
*httptest.Server
|
||||||
key string
|
key string
|
||||||
|
// ingestKey is an integration key for the default team: the only way in
|
||||||
|
// since the unauthenticated webhook was removed, so the tests exercise the
|
||||||
|
// same path production does.
|
||||||
|
ingestKey string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
notify api.NotifyConfig
|
notify api.NotifyConfig
|
||||||
deadman api.DeadmanConfig
|
deadman api.DeadmanConfig
|
||||||
@@ -48,7 +52,7 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
|||||||
}
|
}
|
||||||
|
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
srv := httptest.NewServer(api.NewRouter(database, cfg))
|
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||||
@@ -66,6 +70,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
|||||||
|
|
||||||
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
||||||
|
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/integrations",
|
||||||
|
map[string]string{"name": "test"}), &integration)
|
||||||
|
if integration.Key == "" {
|
||||||
|
t.Fatal("no integration key was returned")
|
||||||
|
}
|
||||||
|
s.ingestKey = integration.Key
|
||||||
|
|
||||||
// Dead man's switches belong to a team now, so a test that wants them
|
// Dead man's switches belong to a team now, so a test that wants them
|
||||||
// configures the default team the way an owner would.
|
// configures the default team the way an owner would.
|
||||||
if deadman.Timeout > 0 {
|
if deadman.Timeout > 0 {
|
||||||
@@ -234,7 +248,8 @@ func postWebhook(t *testing.T, s *ts, alerts []map[string]any, groupKey ...strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(payload)
|
data, _ := json.Marshal(payload)
|
||||||
resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data))
|
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("post webhook: %v", err)
|
t.Fatalf("post webhook: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const (
|
|||||||
|
|
||||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||||
// immediate pass so a restart reconciles state right away.
|
// immediate pass so a restart reconciles state right away.
|
||||||
|
// archiveAfter and staleAfter are the values the server started with. They are
|
||||||
|
// the fallback, not the setting: each pass reads the current value from the
|
||||||
|
// settings table, so an administrator's change takes effect on the next tick
|
||||||
|
// instead of at the next restart.
|
||||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
ticker := time.NewTicker(sweepInterval)
|
ticker := time.NewTicker(sweepInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -45,6 +49,10 @@ func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter tim
|
|||||||
// staleness rules would otherwise resolve it as 'expiry' long before that.
|
// staleness rules would otherwise resolve it as 'expiry' long before that.
|
||||||
// Exported so tests can drive a pass without waiting on the ticker.
|
// Exported so tests can drive a pass without waiting on the ticker.
|
||||||
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
|
settings := NewSettings(db)
|
||||||
|
staleAfter = settings.Duration(ctx, SettingStaleAfter, staleAfter)
|
||||||
|
archiveAfter = settings.Duration(ctx, SettingArchiveAfter, archiveAfter)
|
||||||
|
|
||||||
heartbeats := sweepDeadman(ctx, db, notify)
|
heartbeats := sweepDeadman(ctx, db, notify)
|
||||||
expireStale(ctx, db, staleAfter, heartbeats)
|
expireStale(ctx, db, staleAfter, heartbeats)
|
||||||
resolveSettledIncidents(ctx, db)
|
resolveSettledIncidents(ctx, db)
|
||||||
|
|||||||
+42
-22
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
|
|||||||
return string(h), err
|
return string(h), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startSession mints a session and sets the cookie. Shared by login and
|
||||||
|
// sign-up: somebody who has just chosen a password is signed in, rather than
|
||||||
|
// being sent to a form to type the same credential again.
|
||||||
|
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
||||||
|
raw, tokenHash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: raw,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cookieSecure(publicURL, r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogin exchanges a username and password for a session cookie.
|
// handleLogin exchanges a username and password for a session cookie.
|
||||||
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
|||||||
}
|
}
|
||||||
limiter.clear(userKey)
|
limiter.clear(userKey)
|
||||||
|
|
||||||
raw, tokenHash, err := randomToken()
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
|
||||||
if _, err := db.ExecContext(r.Context(), `
|
|
||||||
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
||||||
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: raw,
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: int(sessionTTL.Seconds()),
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: cookieSecure(publicURL, r),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
user, err := fetchUser(r.Context(), db, userID)
|
user, err := fetchUser(r.Context(), db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
|||||||
type meResponse struct {
|
type meResponse struct {
|
||||||
User any `json:"user"`
|
User any `json:"user"`
|
||||||
HasPassword bool `json:"has_password"`
|
HasPassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// OnboardingDismissed is whether this person has put the first-run
|
||||||
|
// checklist away. Per user rather than per browser: somebody who finishes
|
||||||
|
// setting up on a laptop should not be nagged again on their phone.
|
||||||
|
OnboardingDismissed bool `json:"onboarding_dismissed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMe says who the caller is. The web UI calls it on load to decide
|
// handleMe says who the caller is. The web UI calls it on load to decide
|
||||||
@@ -256,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var hash sql.NullString
|
var hash sql.NullString
|
||||||
|
var dismissed *int64
|
||||||
db.QueryRowContext(r.Context(),
|
db.QueryRowContext(r.Context(),
|
||||||
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
|
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||||
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
|
caller.ID).Scan(&hash, &dismissed)
|
||||||
|
respond(w, http.StatusOK, meResponse{
|
||||||
|
User: user,
|
||||||
|
HasPassword: hash.Valid,
|
||||||
|
OnboardingDismissed: dismissed != nil,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
|
|||||||
|
|
||||||
func TestBootstrap_WithPassword(t *testing.T) {
|
func TestBootstrap_WithPassword(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}))
|
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evEscalated records a rung of the ladder on the incident's timeline: which
|
||||||
|
// level, and who it woke.
|
||||||
|
const evEscalated = "escalated"
|
||||||
|
|
||||||
|
// escalationPolicy is a team's ladder, loaded whole. It is small — a handful of
|
||||||
|
// levels with a few targets each — and every use needs all of it, so there is
|
||||||
|
// no point reading it a level at a time.
|
||||||
|
type escalationPolicy struct {
|
||||||
|
teamID int64
|
||||||
|
repeatCount int64
|
||||||
|
fallbackTopic string
|
||||||
|
levels []escalationLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevel struct {
|
||||||
|
id int64
|
||||||
|
position int64
|
||||||
|
timeout time.Duration
|
||||||
|
targets []escalationTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationTarget struct {
|
||||||
|
kind string // "user" or "oncall"
|
||||||
|
userID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// configured reports whether this team has anything to escalate through. A
|
||||||
|
// policy row with no levels is the same as no policy: the team gets the
|
||||||
|
// pre-escalation behaviour, which is reminders on the assignee's topic.
|
||||||
|
func (p *escalationPolicy) configured() bool { return p != nil && len(p.levels) > 0 }
|
||||||
|
|
||||||
|
// level returns the level at a 1-based position.
|
||||||
|
func (p *escalationPolicy) level(pos int64) (escalationLevel, bool) {
|
||||||
|
for _, l := range p.levels {
|
||||||
|
if l.position == pos {
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escalationLevel{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEscalationPolicy reads one team's ladder. A team with no policy row
|
||||||
|
// returns nil, which every caller treats as "not configured" rather than as an
|
||||||
|
// error: most teams will never set one up.
|
||||||
|
func loadEscalationPolicy(ctx context.Context, q querier, teamID int64) (*escalationPolicy, error) {
|
||||||
|
p := &escalationPolicy{teamID: teamID}
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
"SELECT repeat_count, fallback_topic FROM escalation_policies WHERE team_id = $1",
|
||||||
|
teamID).Scan(&p.repeatCount, &p.fallbackTopic)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT l.id, l.position, l.timeout_seconds, t.kind, t.user_id
|
||||||
|
FROM escalation_levels l
|
||||||
|
LEFT JOIN escalation_targets t ON t.level_id = l.id
|
||||||
|
WHERE l.team_id = $1
|
||||||
|
ORDER BY l.position, t.id`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byPosition := map[int64]int{} // position -> index in p.levels
|
||||||
|
for rows.Next() {
|
||||||
|
var id, position, timeout int64
|
||||||
|
var kind *string
|
||||||
|
var userID *int64
|
||||||
|
if err := rows.Scan(&id, &position, &timeout, &kind, &userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idx, seen := byPosition[position]
|
||||||
|
if !seen {
|
||||||
|
p.levels = append(p.levels, escalationLevel{
|
||||||
|
id: id,
|
||||||
|
position: position,
|
||||||
|
timeout: time.Duration(timeout) * time.Second,
|
||||||
|
})
|
||||||
|
idx = len(p.levels) - 1
|
||||||
|
byPosition[position] = idx
|
||||||
|
}
|
||||||
|
// LEFT JOIN: a level with no targets yet still produces a row, with a
|
||||||
|
// NULL kind. It is a rung that pages nobody, which the API refuses to
|
||||||
|
// store but an older row could still hold.
|
||||||
|
if kind != nil {
|
||||||
|
p.levels[idx].targets = append(p.levels[idx].targets,
|
||||||
|
escalationTarget{kind: *kind, userID: userID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalate advances every incident whose current level has run out of time.
|
||||||
|
//
|
||||||
|
// Runs on the notifier's tick, beside the reminder pass, because it is the same
|
||||||
|
// question asked differently: reminders ask "has this been ignored long
|
||||||
|
// enough to say it again", escalation asks "long enough to say it to somebody
|
||||||
|
// else". Sharing the tick means one query cadence and one outbox.
|
||||||
|
func escalate(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT i.id, i.team_id, i.escalation_level, i.escalation_level_at, i.escalation_round
|
||||||
|
FROM incidents i
|
||||||
|
JOIN escalation_policies p ON p.team_id = i.team_id
|
||||||
|
WHERE i.resolved_at IS NULL
|
||||||
|
AND i.archived_at IS NULL
|
||||||
|
AND i.status = 'triggered'
|
||||||
|
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $1)
|
||||||
|
AND i.escalation_level > 0`, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("escalation: find due: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type pending struct {
|
||||||
|
incidentID, teamID, level, round int64
|
||||||
|
levelAt int64
|
||||||
|
}
|
||||||
|
var due []pending
|
||||||
|
for rows.Next() {
|
||||||
|
var p pending
|
||||||
|
var levelAt *int64
|
||||||
|
if err := rows.Scan(&p.incidentID, &p.teamID, &p.level, &levelAt, &p.round); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Printf("escalation: scan: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if levelAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.levelAt = *levelAt
|
||||||
|
due = append(due, p)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Printf("escalation: iterate: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, d := range due {
|
||||||
|
policy, err := loadEscalationPolicy(ctx, db, d.teamID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("escalation: load policy for team %d: %v", d.teamID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !policy.configured() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, ok := policy.level(d.level)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(time.Unix(d.levelAt, 0)) < current.timeout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := advanceEscalation(ctx, db, cfg, policy, d.incidentID, d.level, d.round, now); err != nil {
|
||||||
|
log.Printf("escalation: advance incident %d: %v", d.incidentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceEscalation moves one incident to its next rung, or off the end of the
|
||||||
|
// ladder.
|
||||||
|
//
|
||||||
|
// The whole move is one transaction: the level, the page and the timeline entry
|
||||||
|
// are one event, and an incident recorded as being at level 3 that nobody at
|
||||||
|
// level 3 was told about is the worst of the possible half-states.
|
||||||
|
func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy *escalationPolicy, incidentID, level, round int64, now time.Time) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
next := level + 1
|
||||||
|
nextRound := round
|
||||||
|
if _, ok := policy.level(next); !ok {
|
||||||
|
// Off the end. Either start the chain again, or make the last call.
|
||||||
|
if round < policy.repeatCount {
|
||||||
|
next, nextRound = 1, round+1
|
||||||
|
} else {
|
||||||
|
if err := escalationExhausted(ctx, tx, policy, incidentID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, ok := policy.level(next)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paged, err := pageLevel(ctx, tx, cfg, policy, incidentID, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE incidents
|
||||||
|
SET escalation_level = $1, escalation_level_at = $2, escalation_round = $3
|
||||||
|
WHERE id = $4`, next, now.Unix(), nextRound, incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := "level " + strconv.FormatInt(next, 10)
|
||||||
|
if nextRound > round {
|
||||||
|
detail += " (round " + strconv.FormatInt(nextRound+1, 10) + ")"
|
||||||
|
}
|
||||||
|
if len(paged) > 0 {
|
||||||
|
detail += ": " + strings.Join(paged, ", ")
|
||||||
|
} else {
|
||||||
|
// Worth recording loudly: the rung exists, its turn came, and it woke
|
||||||
|
// nobody. That is a policy that looks configured and is not.
|
||||||
|
detail += ": nobody reachable"
|
||||||
|
}
|
||||||
|
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationExhausted is the end of the line: the fallback topic, once, and a
|
||||||
|
// timeline entry saying the ladder is finished. The incident stays triggered —
|
||||||
|
// escalation running out is not the same as somebody answering.
|
||||||
|
func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPolicy, incidentID int64, now time.Time) error {
|
||||||
|
detail := "escalation exhausted"
|
||||||
|
if policy.fallbackTopic != "" {
|
||||||
|
if err := enqueueNotification(ctx, tx, incidentID, nil, policy.fallbackTopic, notifyEscalated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
detail += ": paged " + policy.fallbackTopic
|
||||||
|
} else {
|
||||||
|
detail += ": no fallback topic configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 0 again, so the sweep stops considering it. The round counter is
|
||||||
|
// left where it is, as the record of how far it got.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||||
|
incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageLevel notifies every target of one level and reports who was woken.
|
||||||
|
//
|
||||||
|
// Each target gets its own outbox row, so each gets its own Acknowledge token:
|
||||||
|
// the button in a notification must acknowledge as the person holding the
|
||||||
|
// phone, not as whoever was paged first.
|
||||||
|
func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, level escalationLevel) ([]string, error) {
|
||||||
|
var paged []string
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
|
||||||
|
for _, t := range level.targets {
|
||||||
|
userID := t.userID
|
||||||
|
if t.kind == "oncall" {
|
||||||
|
onCall, err := currentOnCall(ctx, tx, policy.teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if onCall == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID = onCall
|
||||||
|
}
|
||||||
|
if userID == nil || seen[*userID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[*userID] = true
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
var username string
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT ntfy_topic, username FROM users WHERE id = $1 AND disabled_at IS NULL",
|
||||||
|
*userID).Scan(&topic, &username); err != nil {
|
||||||
|
// A disabled or deleted account is not an error in the middle of an
|
||||||
|
// escalation: it is a target that cannot be woken, and the next
|
||||||
|
// level is the answer to that.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if topic == nil || *topic == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := enqueueNotification(ctx, tx, incidentID, userID, *topic, notifyEscalated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paged = append(paged, username)
|
||||||
|
}
|
||||||
|
return paged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startEscalation puts a newly opened incident on the first rung, when its team
|
||||||
|
// has a ladder. Called from openIncident, inside the same transaction, so an
|
||||||
|
// incident is never briefly open with no escalation clock running.
|
||||||
|
func startEscalation(ctx context.Context, q querier, incidentID, teamID int64) error {
|
||||||
|
policy, err := loadEscalationPolicy(ctx, q, teamID)
|
||||||
|
if err != nil || !policy.configured() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = q.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 1, escalation_level_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Unix(), incidentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopEscalation takes an incident off the ladder. Acknowledging or resolving
|
||||||
|
// is somebody saying "I have this", and continuing to wake people after that is
|
||||||
|
// the behaviour that teaches people to ignore the tool.
|
||||||
|
func stopEscalation(ctx context.Context, q querier, incidentID int64) error {
|
||||||
|
_, err := q.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||||
|
incidentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetEscalation returns a team's ladder.
|
||||||
|
func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevelJSON struct {
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Targets []escalationTargetJSON `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationTargetJSON struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
UserID *int64 `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationJSON struct {
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
RepeatCount int64 `json:"repeat_count"`
|
||||||
|
FallbackTopic string `json:"fallback_topic"`
|
||||||
|
Levels []escalationLevelJSON `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
|
||||||
|
out := escalationJSON{TeamID: teamID, Levels: []escalationLevelJSON{}}
|
||||||
|
if p == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.RepeatCount = p.repeatCount
|
||||||
|
out.FallbackTopic = p.fallbackTopic
|
||||||
|
for _, l := range p.levels {
|
||||||
|
level := escalationLevelJSON{
|
||||||
|
Position: l.position,
|
||||||
|
TimeoutSeconds: int64(l.timeout.Seconds()),
|
||||||
|
Targets: []escalationTargetJSON{},
|
||||||
|
}
|
||||||
|
for _, t := range l.targets {
|
||||||
|
level.Targets = append(level.Targets, escalationTargetJSON{Kind: t.kind, UserID: t.userID})
|
||||||
|
}
|
||||||
|
out.Levels = append(out.Levels, level)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetEscalation replaces a team's ladder wholesale.
|
||||||
|
//
|
||||||
|
// Replace rather than patch: the levels are an order, and an API that edits one
|
||||||
|
// rung has to answer what happens to the numbering of the others. Sending the
|
||||||
|
// whole ladder makes the order the client's to decide and the server's to
|
||||||
|
// store, and makes an edit atomic — there is no moment where level 2 exists
|
||||||
|
// twice.
|
||||||
|
func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req escalationJSON
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RepeatCount < 0 || req.RepeatCount > 10 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("repeat_count must be between 0 and 10"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, l := range req.Levels {
|
||||||
|
if l.TimeoutSeconds <= 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("every level needs a timeout"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(l.Targets) == 0 {
|
||||||
|
// A rung that pages nobody is not a delay, it is a silence with
|
||||||
|
// a number on it.
|
||||||
|
respond(w, http.StatusBadRequest,
|
||||||
|
errResp("level "+strconv.FormatInt(int64(i+1), 10)+" has no targets"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
switch t.Kind {
|
||||||
|
case "oncall":
|
||||||
|
if t.UserID != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("an oncall target takes no user_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
if t.UserID == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("a user target needs a user_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
respond(w, http.StatusBadRequest, errResp("target kind must be user or oncall"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_policies (team_id, repeat_count, fallback_topic, updated_at)
|
||||||
|
VALUES ($1, $2, $3, `+nowEpoch+`)
|
||||||
|
ON CONFLICT (team_id) DO UPDATE SET
|
||||||
|
repeat_count = excluded.repeat_count,
|
||||||
|
fallback_topic = excluded.fallback_topic,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The levels are replaced, not merged; the cascade takes the targets.
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM escalation_levels WHERE team_id = $1", teamID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, l := range req.Levels {
|
||||||
|
var levelID int64
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_levels (team_id, position, timeout_seconds)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
teamID, int64(i+1), l.TimeoutSeconds).Scan(&levelID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_targets (level_id, kind, user_id)
|
||||||
|
VALUES ($1, $2, $3)`, levelID, t.Kind, t.UserID); err != nil {
|
||||||
|
// The only foreign key here is the user.
|
||||||
|
respond(w, http.StatusBadRequest, errResp("unknown user in targets"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamUser creates a user in the default team with an ntfy topic, so they can
|
||||||
|
// actually be paged.
|
||||||
|
func teamUser(t *testing.T, s *ts, username, topic string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": username, "email": username + "@test.com"}), &user)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
setTopic(t, s, int(user.ID), topic)
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalation is all timeouts, and there is no fake clock in this package. The
|
||||||
|
// tests back-date escalation_level_at instead, which is the same trick the dead
|
||||||
|
// man's switch tests use on received_at: the sweeper reads a stored timestamp,
|
||||||
|
// so moving the timestamp is moving the clock.
|
||||||
|
|
||||||
|
// ladder configures the default team with two levels: the rota first, then a
|
||||||
|
// named person, then the fallback topic.
|
||||||
|
func ladder(t *testing.T, s *ts, secondUserID int64, repeat int64, fallback string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||||
|
"repeat_count": repeat,
|
||||||
|
"fallback_topic": fallback,
|
||||||
|
"levels": []map[string]any{
|
||||||
|
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}},
|
||||||
|
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user", "user_id": secondUserID}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("configure the ladder: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overdue back-dates an incident's current level so its timeout has passed.
|
||||||
|
func overdue(t *testing.T, s *ts, incidentID int64) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE incidents SET escalation_level_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Add(-time.Hour).Unix(), incidentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escalationLevel(t *testing.T, s *ts, incidentID int64) (level, round int64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
"SELECT escalation_level, escalation_round FROM incidents WHERE id = $1",
|
||||||
|
incidentID).Scan(&level, &round); err != nil {
|
||||||
|
t.Fatalf("read escalation state: %v", err)
|
||||||
|
}
|
||||||
|
return level, round
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: nobody answers, so somebody else is woken.
|
||||||
|
func TestEscalation_PagesTheNextLevel(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-esc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Level 1 is the rota, so the first page went to the admin.
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||||
|
t.Fatalf("a new incident should start at level 1, got %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) == 0 || got[0] != "terdut-admin" {
|
||||||
|
t.Fatalf("the first page should go to the on-call user, went to %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time passes with no acknowledgement.
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("expected level 2, got %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-second" {
|
||||||
|
t.Errorf("level 2 should page the named user, paged %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the timeline says so, which is what somebody reads afterwards to
|
||||||
|
// understand why their phone rang at 04:00.
|
||||||
|
timeline := list(t, s.req(t, http.MethodGet, "/api/incidents/1/timeline", nil))
|
||||||
|
found := ""
|
||||||
|
for _, e := range timeline {
|
||||||
|
if e["type"] == "escalated" {
|
||||||
|
found, _ = e["detail"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == "" {
|
||||||
|
t.Error("the timeline should record the escalation")
|
||||||
|
} else if !strings.HasPrefix(found, "level 2") || !strings.Contains(found, "second") {
|
||||||
|
t.Errorf("the escalation entry should say which level and who: %q", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledging is somebody saying "I have this". Nobody else should be woken.
|
||||||
|
func TestEscalation_AcknowledgementStopsIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-ack", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("acknowledging should take the incident off the ladder, level is %d", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1) // no-op: level is 0, so there is nothing due
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("an acknowledged incident should page nobody, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolving stops it too, and by the same mechanism.
|
||||||
|
func TestEscalation_ResolutionStopsIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-res", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("a resolved incident should be off the ladder, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snoozing is a deliberate "not now", so the ladder waits rather than carrying
|
||||||
|
// on without the person who asked for quiet.
|
||||||
|
func TestEscalation_SnoozePausesIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-snooze", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze", map[string]any{"duration": "1h"})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||||
|
t.Errorf("a snoozed incident should stay where it is, level is %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a snoozed incident should page nobody, paged %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the snooze ends, the ladder picks up where it left off.
|
||||||
|
s.exec(t, "UPDATE incidents SET snoozed_until = $1 WHERE id = 1", time.Now().Add(-time.Minute).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("after the snooze the ladder should resume, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running out of ladder pages the team's fallback topic once, and says so.
|
||||||
|
func TestEscalation_ExhaustionPagesTheFallback(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-end", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // level 2
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // off the end
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-fallback" {
|
||||||
|
t.Errorf("exhaustion should page the fallback topic once, paged %v", got)
|
||||||
|
}
|
||||||
|
level, _ := escalationLevel(t, s, 1)
|
||||||
|
if level != 0 {
|
||||||
|
t.Errorf("an exhausted ladder should stop asking, level is %d", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The incident is still open: running out of people is not an answer.
|
||||||
|
var status string
|
||||||
|
if err := s.db.QueryRow("SELECT status FROM incidents WHERE id = 1").Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "triggered" {
|
||||||
|
t.Errorf("exhaustion must not resolve the incident, status is %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat_count walks the whole ladder again before giving up.
|
||||||
|
func TestEscalation_RepeatsTheChain(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 1, "terdut-fallback") // one extra round
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-repeat", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // level 2
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // back to level 1, round 2
|
||||||
|
|
||||||
|
level, round := escalationLevel(t, s, 1)
|
||||||
|
if level != 1 || round != 1 {
|
||||||
|
t.Errorf("expected level 1 round 1, got level %d round %d", level, round)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||||
|
t.Errorf("the second round should start at the top again, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A team without a ladder keeps exactly the behaviour it had, and never gets
|
||||||
|
// both a reminder and an escalation for the same silence.
|
||||||
|
func TestEscalation_WithoutAPolicyRemindersStillRun(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-noesc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Age the first notification past the repeat interval.
|
||||||
|
f.forget()
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||||
|
time.Now().Add(-time.Hour).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||||
|
t.Errorf("without a ladder the reminder should still fire, paged %v", got)
|
||||||
|
}
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("an incident in a team with no ladder should not be on one, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a ladder, reminders stop: two pages for one silence is how people learn
|
||||||
|
// to mute the tool.
|
||||||
|
func TestEscalation_WithAPolicyRemindersDoNotAlsoFire(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-both", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
// Old enough for a reminder, but not yet due for escalation.
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||||
|
time.Now().Add(-time.Hour).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a team with a ladder should not also get reminders, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API refuses a ladder that cannot page anybody.
|
||||||
|
func TestEscalation_RejectsAnUnusablePolicy(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"a level with no targets", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{}}},
|
||||||
|
}},
|
||||||
|
{"a level with no timeout", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 0, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
}},
|
||||||
|
{"a user target with no user", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user"}}}},
|
||||||
|
}},
|
||||||
|
{"an unknown target kind", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "everybody"}}}},
|
||||||
|
}},
|
||||||
|
{"an absurd repeat count", map[string]any{
|
||||||
|
"repeat_count": 99,
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing the ladder is an owner's job; reading it is any member's.
|
||||||
|
func TestEscalation_OwnerOnlyToEdit(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member editing the ladder: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = call(http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("a member reading the ladder: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target who cannot be woken is not a reason to stop: the next level is the
|
||||||
|
// answer to an unreachable one.
|
||||||
|
func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
// Second user has no ntfy topic at all.
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "silent", "email": "silent@test.com"}), &user)
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
|
||||||
|
|
||||||
|
ladder(t, s, user.ID, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-silent", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Level 2 was entered even though it woke nobody, so the ladder keeps
|
||||||
|
// moving toward the fallback rather than stalling on a silent rung.
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("expected the ladder to advance past an unreachable target, level is %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,13 @@ type querier interface {
|
|||||||
|
|
||||||
const incidentSelectFrom = `
|
const incidentSelectFrom = `
|
||||||
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
||||||
|
i.escalation_level,
|
||||||
|
-- When this level runs out. Computed here rather than in Go because
|
||||||
|
-- the timeout lives beside the level in the policy, and one join is
|
||||||
|
-- cheaper than a second query per incident in a list.
|
||||||
|
(SELECT i.escalation_level_at + el.timeout_seconds
|
||||||
|
FROM escalation_levels el
|
||||||
|
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
|
||||||
i.triggered_at,
|
i.triggered_at,
|
||||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||||
i.assigned_to, asg.username, i.snoozed_until,
|
i.assigned_to, asg.username, i.snoozed_until,
|
||||||
@@ -65,10 +72,11 @@ func scanIncident(s scanner) (models.Incident, error) {
|
|||||||
var i models.Incident
|
var i models.Incident
|
||||||
var groupLabelsJSON string
|
var groupLabelsJSON string
|
||||||
var triggeredAt int64
|
var triggeredAt int64
|
||||||
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
|
var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64
|
||||||
|
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
||||||
|
&i.EscalationLevel, &escalationDue,
|
||||||
&triggeredAt,
|
&triggeredAt,
|
||||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||||
@@ -83,6 +91,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
|||||||
i.SnoozedUntil = unixPtr(snoozedUntil)
|
i.SnoozedUntil = unixPtr(snoozedUntil)
|
||||||
i.ResolvedAt = unixPtr(resolvedAt)
|
i.ResolvedAt = unixPtr(resolvedAt)
|
||||||
i.ArchivedAt = unixPtr(archivedAt)
|
i.ArchivedAt = unixPtr(archivedAt)
|
||||||
|
i.EscalationDueAt = unixPtr(escalationDue)
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +244,9 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -260,6 +272,10 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
|
|||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
// Somebody has it: stop waking anybody else.
|
||||||
|
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
|||||||
time.Now().Unix(), incidentResolutionManual, id) {
|
time.Now().Unix(), incidentResolutionManual, id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A person closing an incident is the clearest possible "I have this".
|
||||||
|
if err := stopEscalation(r.Context(), db, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
|
|||||||
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
||||||
var u models.User
|
var u models.User
|
||||||
var createdUnix int64
|
var createdUnix int64
|
||||||
|
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
|
||||||
|
// a disabled account is one that cannot authenticate, by either credential,
|
||||||
|
// and the way to be sure of that is for there to be no path where the row
|
||||||
|
// is loaded and the flag is then forgotten.
|
||||||
if err := db.QueryRowContext(r.Context(),
|
if err := db.QueryRowContext(r.Context(),
|
||||||
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID,
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
|
||||||
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const (
|
|||||||
notifyTriggered = "triggered"
|
notifyTriggered = "triggered"
|
||||||
notifyReminder = "reminder"
|
notifyReminder = "reminder"
|
||||||
notifyResolved = "resolved"
|
notifyResolved = "resolved"
|
||||||
|
|
||||||
|
// notifyEscalated is a page that went out because nobody answered the last
|
||||||
|
// one. Told apart from a reminder because it goes to somebody else.
|
||||||
|
notifyEscalated = "escalated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Timeline event types the notifier writes, so an incident's history says who
|
// Timeline event types the notifier writes, so an incident's history says who
|
||||||
@@ -120,6 +124,9 @@ func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
|||||||
// Exported so tests can drive a pass without waiting on the ticker.
|
// Exported so tests can drive a pass without waiting on the ticker.
|
||||||
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
enqueueReminders(ctx, db, cfg)
|
enqueueReminders(ctx, db, cfg)
|
||||||
|
// Escalation before delivery, so a level that comes due on this tick is
|
||||||
|
// paged on this tick rather than waiting for the next one.
|
||||||
|
escalate(ctx, db, cfg)
|
||||||
deliverPending(ctx, db, cfg)
|
deliverPending(ctx, db, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +141,11 @@ func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
|||||||
// queued, so an ntfy outage produces a retry backlog rather than a reminder
|
// queued, so an ntfy outage produces a retry backlog rather than a reminder
|
||||||
// backlog that all lands at once when it comes back.
|
// backlog that all lands at once when it comes back.
|
||||||
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
if cfg.RepeatEvery <= 0 {
|
// cfg.RepeatEvery is what the server started with; the settings table is
|
||||||
|
// what it runs on. Read per tick, so an administrator lengthening the
|
||||||
|
// interval at 02:00 is obeyed at 02:00 and not at the next restart.
|
||||||
|
repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
|
||||||
|
if repeat <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -155,8 +166,13 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
|||||||
AND i.resolved_at IS NULL
|
AND i.resolved_at IS NULL
|
||||||
AND i.archived_at IS NULL
|
AND i.archived_at IS NULL
|
||||||
AND i.status = 'triggered'
|
AND i.status = 'triggered'
|
||||||
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)`,
|
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
|
||||||
now.Add(-cfg.RepeatEvery).Unix(), now.Unix())
|
-- A team with an escalation ladder gets escalation instead. Both
|
||||||
|
-- would mean two pages for one silence, which is how people learn to
|
||||||
|
-- mute a tool.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)`,
|
||||||
|
now.Add(-repeat).Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("notifier: find reminders: %v", err)
|
log.Printf("notifier: find reminders: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -69,6 +69,27 @@ func (f *fakeNtfy) messages() []pushed {
|
|||||||
return append([]pushed(nil), f.got...)
|
return append([]pushed(nil), f.got...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// topicsSince lists the topics published to since the last forget, which is how
|
||||||
|
// the escalation tests ask "who did this tick wake".
|
||||||
|
func (f *fakeNtfy) topicsSince(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(f.got))
|
||||||
|
for _, m := range f.got {
|
||||||
|
out = append(out, m.Topic)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// forget drops what has been published so far, so the next assertion is about
|
||||||
|
// this tick rather than the whole test.
|
||||||
|
func (f *fakeNtfy) forget() {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.got = nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeNtfy) failWith(status int) {
|
func (f *fakeNtfy) failWith(status int) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|||||||
+47
-7
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -13,7 +14,13 @@ import (
|
|||||||
// the only handler that has to decide where a new incident's page goes; a zero
|
// the only handler that has to decide where a new incident's page goes; a zero
|
||||||
// notify disables notifications. Dead man's switches are per team and read from
|
// notify disables notifications. Dead man's switches are per team and read from
|
||||||
// the database, so nothing about them is wired in here.
|
// the database, so nothing about them is wired in here.
|
||||||
func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
||||||
|
// One limiter each, both process-wide for the life of the router: login
|
||||||
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||||
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||||
|
loginLimit := newLoginLimiter()
|
||||||
|
signupLimiter := newLoginLimiter()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -31,17 +38,23 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
|
|
||||||
// Alert ingestion. The key in the path says both that the sender may post
|
// Alert ingestion. The key in the path says both that the sender may post
|
||||||
// and which team the alerts belong to, which is why it needs no session.
|
// and which team the alerts belong to, which is why it needs no session.
|
||||||
|
//
|
||||||
|
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
|
||||||
|
// took no credential at all, was removed in v0.13.0 once the cluster's
|
||||||
|
// Alertmanager had moved onto a key; a sender still posting there gets the
|
||||||
|
// JSON 404 every unknown /api path gets.
|
||||||
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
||||||
|
|
||||||
// DEPRECATED, and unauthenticated: anything that can reach the port can
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
||||||
// open an incident here. Kept for one release so an upgrade does not stop
|
// account yet. The info endpoint says whether the door is open and whether
|
||||||
// delivering while the Alertmanager config is edited; it routes everything
|
// an invite link is good, so the form can say so before somebody picks a
|
||||||
// to the oldest team. Remove it once senders carry a key.
|
// password.
|
||||||
r.Post("/api/alertmanager/webhook", handleLegacyWebhook(db, notify))
|
r.Get("/api/signup", handleSignupInfo(db))
|
||||||
|
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
||||||
|
|
||||||
// Signing in to the web UI. Login trades a password for a session cookie,
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||||
// which AuthMiddleware accepts in place of an API key.
|
// which AuthMiddleware accepts in place of an API key.
|
||||||
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
|
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
||||||
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
||||||
|
|
||||||
// All other /api routes require a valid API key.
|
// All other /api routes require a valid API key.
|
||||||
@@ -49,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
r.Use(AuthMiddleware(db))
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
r.Get("/api/me", handleMe(db))
|
r.Get("/api/me", handleMe(db))
|
||||||
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
||||||
|
// Proves the topic works, which is the only part of "notifications are
|
||||||
|
// set up" that the person holding the phone can confirm.
|
||||||
|
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
|
||||||
|
|
||||||
// Readable by anyone signed in: the queue's assignment control and the
|
// Readable by anyone signed in: the queue's assignment control and the
|
||||||
// on-call schedule both need to name people.
|
// on-call schedule both need to name people.
|
||||||
@@ -57,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
// Your own account, or anybody's if you are an admin. The handlers call
|
// Your own account, or anybody's if you are an admin. The handlers call
|
||||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||||
// which rule applies depends on the {id} in the path.
|
// 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}/notify", handleSetNotifyTarget(db))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
@@ -71,6 +89,18 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
r.Post("/api/users", handleCreateUser(db))
|
r.Post("/api/users", handleCreateUser(db))
|
||||||
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||||
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
||||||
|
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
||||||
|
|
||||||
|
// 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))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Alerts are read-only: they are Alertmanager's record, not a work
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
||||||
@@ -96,11 +126,21 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
// Teams. A user sees the teams they belong to; an owner configures one.
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||||
r.Get("/api/teams", handleListTeams(db))
|
r.Get("/api/teams", handleListTeams(db))
|
||||||
r.Post("/api/teams", handleCreateTeam(db))
|
r.Post("/api/teams", handleCreateTeam(db))
|
||||||
|
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||||
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||||
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
||||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||||
|
|
||||||
|
// Invite links into this team.
|
||||||
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||||
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||||
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
||||||
|
|
||||||
|
// A team's escalation ladder: who is paged when nobody answers.
|
||||||
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
||||||
|
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||||
|
|
||||||
// A team's own dead man's switches: which of its alerts are heartbeats,
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
||||||
// and how long a silence has to last before somebody is paged.
|
// and how long a silence has to last before somebody is paged.
|
||||||
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
|
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings an administrator can change at runtime. Each is behaviour rather
|
||||||
|
// than infrastructure: what the server does, not where it is plugged in.
|
||||||
|
//
|
||||||
|
// The values are seconds, stored as text. A duration string would be friendlier
|
||||||
|
// to read in psql and worse everywhere else — it can be stored unparseable, and
|
||||||
|
// then the question is what a background loop should do at 02:00 with a
|
||||||
|
// tuning knob it cannot understand.
|
||||||
|
const (
|
||||||
|
SettingNotifyRepeat = "notify_repeat_seconds"
|
||||||
|
SettingStaleAfter = "stale_after_seconds"
|
||||||
|
SettingArchiveAfter = "archive_after_seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingBounds keeps an edit from producing a server that cannot work. The
|
||||||
|
// ceilings are loose — they exist to catch a slipped decimal point, not to have
|
||||||
|
// an opinion about anybody's rota.
|
||||||
|
var settingBounds = map[string]struct {
|
||||||
|
min, max time.Duration
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
|
||||||
|
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
|
||||||
|
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings reads the runtime configuration. It holds no cache: the readers are
|
||||||
|
// two background loops that tick every 30 seconds and 15 minutes, and handlers
|
||||||
|
// that run once per request, so a query each time costs nothing measurable and
|
||||||
|
// means an administrator's change takes effect on the next tick rather than at
|
||||||
|
// the next restart.
|
||||||
|
type Settings struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewSettings returns a reader over db.
|
||||||
|
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
|
||||||
|
|
||||||
|
// Duration reads one setting, falling back to def when the row is missing or
|
||||||
|
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
|
||||||
|
// is the value the server started with.
|
||||||
|
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
|
||||||
|
var raw string
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedSettings writes each key from the server's environment configuration,
|
||||||
|
// once. Never overwrites: after the first start the database owns these, and a
|
||||||
|
// redeploy must not put a chart's default back over an administrator's edit —
|
||||||
|
// the same rule as the per-team dead man's switches.
|
||||||
|
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
||||||
|
seeds := map[string]time.Duration{
|
||||||
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||||
|
SettingStaleAfter: cfg.StaleAfter,
|
||||||
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||||
|
}
|
||||||
|
for key, d := range seeds {
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO settings (key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO NOTHING`,
|
||||||
|
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsResponse is what the admin page renders. The environment half is
|
||||||
|
// included and marked read-only, so somebody looking for the ntfy URL finds out
|
||||||
|
// where it lives rather than concluding the server does not have one.
|
||||||
|
type settingsResponse struct {
|
||||||
|
Editable map[string]settingValue `json:"editable"`
|
||||||
|
FromEnv map[string]string `json:"from_env"`
|
||||||
|
|
||||||
|
// Choices are settings that are a word from a fixed list rather than a
|
||||||
|
// duration. One so far: who may create an account.
|
||||||
|
Choices map[string]choiceValue `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type choiceValue struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingValue struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MinSeconds int64 `json:"min_seconds"`
|
||||||
|
MaxSeconds int64 `json:"max_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
||||||
|
settings := NewSettings(db)
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := settingsResponse{
|
||||||
|
Editable: map[string]settingValue{},
|
||||||
|
Choices: map[string]choiceValue{
|
||||||
|
SettingSignupMode: {
|
||||||
|
Value: signupMode(r.Context(), db),
|
||||||
|
Options: []string{SignupInviteOnly, SignupOpen},
|
||||||
|
Description: "who may create an account: invite_only means a link from a team owner, " +
|
||||||
|
"open means anybody who can reach this server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FromEnv: map[string]string{
|
||||||
|
// Never the ntfy token or the DSN: both are credentials, and an
|
||||||
|
// admin page that renders them turns a browser tab into a place
|
||||||
|
// they leak from.
|
||||||
|
"ntfy_url": cfg.NtfyURL,
|
||||||
|
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
|
||||||
|
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
|
||||||
|
"public_url": cfg.PublicURL,
|
||||||
|
"listen_address": cfg.Addr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for key, b := range settingBounds {
|
||||||
|
def := map[string]time.Duration{
|
||||||
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||||
|
SettingStaleAfter: cfg.StaleAfter,
|
||||||
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||||
|
}[key]
|
||||||
|
out.Editable[key] = settingValue{
|
||||||
|
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
|
||||||
|
Description: b.label,
|
||||||
|
MinSeconds: int64(b.min.Seconds()),
|
||||||
|
MaxSeconds: int64(b.max.Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetSettings changes one or more settings. Unknown keys are refused
|
||||||
|
// rather than stored: a typo that writes notify_repeat_second would otherwise
|
||||||
|
// sit in the table looking like configuration and doing nothing.
|
||||||
|
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req) == 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("no settings given"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate everything before writing anything: a request that sets two
|
||||||
|
// settings and gets one wrong should change neither.
|
||||||
|
values := map[string]string{}
|
||||||
|
for key, raw := range req {
|
||||||
|
switch key {
|
||||||
|
case SettingSignupMode:
|
||||||
|
mode, _ := raw.(string)
|
||||||
|
if mode != SignupOpen && mode != SignupInviteOnly {
|
||||||
|
respond(w, http.StatusBadRequest,
|
||||||
|
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[key] = mode
|
||||||
|
default:
|
||||||
|
b, known := settingBounds[key]
|
||||||
|
if !known {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secs, ok := raw.(float64) // JSON numbers decode as float64
|
||||||
|
if !ok {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := time.Duration(int64(secs)) * time.Second
|
||||||
|
if d < b.min || d > b.max {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(
|
||||||
|
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[key] = strconv.FormatInt(int64(secs), 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
for key, value := range values {
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES ($1, $2, `+nowEpoch+`)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
key, value); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.QueryContext(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
|
||||||
|
ORDER BY t.name`)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []adminTeam{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t adminTeam
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"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
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetUserDisabled takes an account out of use, or puts it back.
|
||||||
|
//
|
||||||
|
// Not a delete: the person's acknowledgements, assignments and timeline entries
|
||||||
|
// stay attached to them. Deleting a user nulls those columns, which rewrites
|
||||||
|
// what happened during an incident months after the fact.
|
||||||
|
func handleSetUserDisabled(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
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Disabled *bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("disabled is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *req.Disabled {
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
if caller.ID == id {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last, err := isLastAdmin(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res sql.Result
|
||||||
|
if *req.Disabled {
|
||||||
|
res, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
|
||||||
|
} else {
|
||||||
|
res, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
// Either no such user, or already in the state asked for. The
|
||||||
|
// second is not a failure, so check which before answering.
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing back in is the only way to use a re-enabled account, and a
|
||||||
|
// disabled one must not keep a live session.
|
||||||
|
if *req.Disabled {
|
||||||
|
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fetchUser(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings an administrator can change, and the ones they cannot.
|
||||||
|
func TestSettings_EditableAndReadOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Editable map[string]struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MinSeconds int64 `json:"min_seconds"`
|
||||||
|
MaxSeconds int64 `json:"max_seconds"`
|
||||||
|
} `json:"editable"`
|
||||||
|
FromEnv map[string]string `json:"from_env"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||||
|
|
||||||
|
// Seeded from the environment the server started with, not from zero.
|
||||||
|
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
|
||||||
|
t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
|
||||||
|
}
|
||||||
|
if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
|
||||||
|
t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
|
||||||
|
}
|
||||||
|
if got.Editable["archive_after_seconds"].Description == "" {
|
||||||
|
t.Error("a setting without a description is a number nobody can act on")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment half is visible so somebody can see where it lives, but
|
||||||
|
// never the credentials themselves.
|
||||||
|
if _, ok := got.FromEnv["public_url"]; !ok {
|
||||||
|
t.Error("public_url should be reported as environment-configured")
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
|
||||||
|
if v, ok := got.FromEnv[leak]; ok {
|
||||||
|
t.Errorf("%s must not be in the settings response (got %q)", leak, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a setting takes effect on the next tick, without a restart. This is
|
||||||
|
// the whole point of moving them out of the environment.
|
||||||
|
func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// An alert whose last webhook was two hours ago. Under the seeded
|
||||||
|
// stale_after of six hours the sweeper leaves it alone.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||||
|
time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
|
||||||
|
t.Fatalf("before the change the alert should still be firing, got %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten it to an hour. Nothing restarts.
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||||
|
map[string]int64{"stale_after_seconds": 3600})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("change setting: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
status, source, _ := s.alertRow(t, "fp-settings")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("after the change the alert should have expired, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected resolution_source expiry, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typo must not look like configuration, and a slipped decimal point must not
|
||||||
|
// produce a server that sweeps every second.
|
||||||
|
func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]int64
|
||||||
|
}{
|
||||||
|
{"unknown key", map[string]int64{"notify_repeat_second": 60}},
|
||||||
|
{"below the floor", map[string]int64{"stale_after_seconds": 30}},
|
||||||
|
{"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
|
||||||
|
{"nothing at all", map[string]int64{}},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings are the server's behaviour, so only an administrator may change
|
||||||
|
// them — or see where the rest of the configuration comes from.
|
||||||
|
func TestSettings_AreAdminOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "member")
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{http.MethodGet, nil},
|
||||||
|
{http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
|
||||||
|
} {
|
||||||
|
resp := call(c.method, "/api/admin/settings", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := call(http.MethodGet, "/api/admin/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator sees every team, including ones they are not in — which is
|
||||||
|
// exactly what /api/teams must not show them.
|
||||||
|
func TestSettings_AdminSeesEveryTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
newTeam(t, s, "red")
|
||||||
|
newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||||
|
if len(all) != 3 { // Default, red, blue
|
||||||
|
t.Fatalf("admin should see all 3 teams, saw %d", len(all))
|
||||||
|
}
|
||||||
|
for _, team := range all {
|
||||||
|
if _, ok := team["members"]; !ok {
|
||||||
|
t.Error("the admin listing should say how big each team is")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin created them, so they own them — but they are not a member of
|
||||||
|
// a team somebody else makes, and /api/teams still answers "what am I in".
|
||||||
|
mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
|
||||||
|
if len(mine) != 3 {
|
||||||
|
t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling is not deleting: the account stops working and the history stays.
|
||||||
|
func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "leaver")
|
||||||
|
|
||||||
|
// They acknowledge an incident, so there is history to preserve.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("acknowledge: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||||
|
map[string]bool{"disabled": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("disable: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their API key stops working.
|
||||||
|
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The acknowledgement still names them.
|
||||||
|
var incident map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
|
||||||
|
if incident["acknowledged_by"] != "leaver" {
|
||||||
|
t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
|
||||||
|
}
|
||||||
|
if incident["status"] != "acknowledged" {
|
||||||
|
t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And re-enabling gives the account back.
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||||
|
map[string]bool{"disabled": false})
|
||||||
|
resp.Body.Close()
|
||||||
|
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same two guards as deleting and demoting: an install must keep somebody
|
||||||
|
// who can administer it.
|
||||||
|
func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming a team is an owner's job, and the name stays unique.
|
||||||
|
func TestSettings_TeamRename(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("rename: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||||
|
found := false
|
||||||
|
for _, x := range teams {
|
||||||
|
if x["name"] == "Platform" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the renamed team should be listed under its new name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking a name that exists is a conflict, not a silent second team with
|
||||||
|
// the same label.
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed runs once. A redeploy must not put the chart's default back over an
|
||||||
|
// administrator's edit — the rule the dead man's switches already follow.
|
||||||
|
func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||||
|
map[string]int64{"notify_repeat_seconds": 60})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// A second start, with the environment still saying 15 minutes.
|
||||||
|
if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
|
||||||
|
t.Fatalf("re-seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Editable map[string]struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
} `json:"editable"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||||
|
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
|
||||||
|
t.Errorf("the edit should survive a restart, got %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingSignupMode says who may create an account. It lives in the settings
|
||||||
|
// table with the other behaviour settings, so an administrator changes it in
|
||||||
|
// the admin page rather than in a chart.
|
||||||
|
//
|
||||||
|
// Two modes, not three. A domain-restricted mode was considered and dropped:
|
||||||
|
// with no email in this server there is nothing to verify an address against,
|
||||||
|
// so it would check the domain of a string somebody typed — a speed bump
|
||||||
|
// dressed as a control.
|
||||||
|
const (
|
||||||
|
SettingSignupMode = "signup_mode"
|
||||||
|
|
||||||
|
SignupInviteOnly = "invite_only"
|
||||||
|
SignupOpen = "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultSignupMode is invite-only. An install that gets a public hostname
|
||||||
|
// before anybody has thought about sign-up should not be collecting accounts
|
||||||
|
// from the internet by default.
|
||||||
|
const defaultSignupMode = SignupInviteOnly
|
||||||
|
|
||||||
|
// inviteTTL is how long a new invite link lives. Long enough to send it and be
|
||||||
|
// read tomorrow, short enough that a link in an old chat log stops working.
|
||||||
|
const inviteTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// signupMode reads the current mode, falling back to invite-only for a missing
|
||||||
|
// or unrecognised value: the failure mode of a typo in this setting should be
|
||||||
|
// the closed door, not the open one.
|
||||||
|
func signupMode(ctx context.Context, db *sql.DB) string {
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
if raw != SignupOpen && raw != SignupInviteOnly {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSignupInfo tells the sign-up page what it may offer, without requiring
|
||||||
|
// a session: whether open sign-up is on, and whether the invite in the URL is
|
||||||
|
// any good. A bad invite is better reported before somebody picks a password.
|
||||||
|
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := map[string]any{"mode": signupMode(r.Context(), db)}
|
||||||
|
|
||||||
|
if token := r.URL.Query().Get("invite"); token != "" {
|
||||||
|
inv, err := loadInvite(r.Context(), db, token)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
out["invite_valid"] = true
|
||||||
|
out["invite_team"] = inv.teamName
|
||||||
|
default:
|
||||||
|
// Deliberately one answer for expired, revoked, used up and
|
||||||
|
// never existed. Telling a stranger which it was tells them
|
||||||
|
// something about links they do not hold.
|
||||||
|
out["invite_valid"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type invite struct {
|
||||||
|
id int64
|
||||||
|
teamID int64
|
||||||
|
teamName string
|
||||||
|
role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
|
||||||
|
// it exists, has not been revoked, has not expired and has uses left.
|
||||||
|
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
|
||||||
|
var inv invite
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT i.id, i.team_id, t.name, i.role
|
||||||
|
FROM invites i
|
||||||
|
JOIN teams t ON t.id = i.team_id
|
||||||
|
WHERE i.token_hash = $1
|
||||||
|
AND i.revoked_at IS NULL
|
||||||
|
AND i.expires_at > `+nowEpoch+`
|
||||||
|
AND i.uses < i.max_uses`, hashToken(token)).
|
||||||
|
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return invite{}, errInviteUnusable
|
||||||
|
}
|
||||||
|
return inv, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInviteUnusable = errors.New("invite is not usable")
|
||||||
|
|
||||||
|
// handleSignup creates an account, and puts it somewhere.
|
||||||
|
//
|
||||||
|
// Rate-limited on the same limiter as login, by address: sign-up is the other
|
||||||
|
// unauthenticated endpoint that writes, and an open install without this is a
|
||||||
|
// way to fill somebody's user table.
|
||||||
|
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
addr := clientAddr(r)
|
||||||
|
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Invite string `json:"invite"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.TeamName = strings.TrimSpace(req.TeamName)
|
||||||
|
|
||||||
|
if req.Username == "" || req.Email == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := signupMode(r.Context(), db)
|
||||||
|
var inv invite
|
||||||
|
hasInvite := false
|
||||||
|
if req.Invite != "" {
|
||||||
|
var err error
|
||||||
|
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||||
|
if err != nil {
|
||||||
|
limiter.fail("signup:" + addr)
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasInvite = true
|
||||||
|
}
|
||||||
|
if !hasInvite && mode != SignupOpen {
|
||||||
|
// No invite and the door is shut. Not 404: the endpoint exists and
|
||||||
|
// saying so is how somebody knows to ask for a link.
|
||||||
|
respond(w, http.StatusForbidden,
|
||||||
|
errResp("sign-up is invite-only on this server"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasInvite && req.TeamName == "" {
|
||||||
|
// Open sign-up with no team would create an account that sees an
|
||||||
|
// empty queue and can be paged by nobody.
|
||||||
|
respond(w, http.StatusBadRequest, errResp("team_name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
var invitedVia *int64
|
||||||
|
if hasInvite {
|
||||||
|
invitedVia = &inv.id
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO users (username, email, password_hash, invited_via)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teamID, role := inv.teamID, inv.role
|
||||||
|
if !hasInvite {
|
||||||
|
// Open sign-up makes a team, and its creator owns it.
|
||||||
|
if err := tx.QueryRowContext(r.Context(),
|
||||||
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role = models.RoleOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||||
|
teamID, userID, role); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasInvite {
|
||||||
|
// Counted inside the transaction, so two people redeeming the last
|
||||||
|
// use of a link at once cannot both get in.
|
||||||
|
res, err := tx.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in immediately: the alternative is a form that says "now go
|
||||||
|
// and log in", which is the same credential typed twice.
|
||||||
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
|
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSignupsPerAddr is looser than the login limit: several people joining from
|
||||||
|
// one office share an address, and the thing being limited is account creation
|
||||||
|
// rather than password guessing.
|
||||||
|
const maxSignupsPerAddr = 10
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invites
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type inviteJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
Uses int64 `json:"uses"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
|
||||||
|
// URL is the whole link, returned once when the invite is created. Like an
|
||||||
|
// integration key, only its hash is stored.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
|
||||||
|
FROM invites
|
||||||
|
WHERE team_id = $1
|
||||||
|
ORDER BY id DESC`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []inviteJSON{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i inviteJSON
|
||||||
|
var created, expires int64
|
||||||
|
var revoked *int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
||||||
|
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
i.Revoked = revoked != nil
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
|
||||||
|
// a team's configuration: deciding who joins is configuring the team.
|
||||||
|
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
req.Role = models.RoleMember
|
||||||
|
}
|
||||||
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MaxUses == 0 {
|
||||||
|
req.MaxUses = 1
|
||||||
|
}
|
||||||
|
if req.MaxUses < 1 || req.MaxUses > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
expires := time.Now().Add(inviteTTL)
|
||||||
|
|
||||||
|
var out inviteJSON
|
||||||
|
var created, expiresAt int64
|
||||||
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||||
|
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
|
||||||
|
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
|
||||||
|
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
|
||||||
|
respond(w, http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeInvite stops a link working without waiting for it to expire.
|
||||||
|
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET revoked_at = "+nowEpoch+
|
||||||
|
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Onboarding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleTestNotification publishes one push to the caller's own topic.
|
||||||
|
//
|
||||||
|
// The point of the first-run checklist's notification step is not that a topic
|
||||||
|
// string has been typed but that a phone buzzes, and only the person holding it
|
||||||
|
// can tell whether it did. Published directly rather than through the outbox:
|
||||||
|
// the outbox row requires an incident, and this deliberately belongs to no
|
||||||
|
// incident.
|
||||||
|
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
respond(w, http.StatusServiceUnavailable,
|
||||||
|
errResp("this server has no ntfy configured, so it can send nothing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if topic == nil || *topic == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publish(r.Context(), cfg, ntfyMessage{
|
||||||
|
Topic: *topic,
|
||||||
|
Title: "terdut test",
|
||||||
|
Message: "If this arrived, your notifications work.",
|
||||||
|
Tags: []string{"white_check_mark"},
|
||||||
|
}); err != nil {
|
||||||
|
// The failure is the useful part here: a wrong topic, a token the
|
||||||
|
// ntfy server rejects, or an ntfy that is down all look the same
|
||||||
|
// from the phone, which is silence.
|
||||||
|
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDismissOnboarding hides the first-run checklist, or brings it back.
|
||||||
|
// Stored per user rather than in the browser: somebody who finishes setting up
|
||||||
|
// on a laptop should not be nagged again on their phone.
|
||||||
|
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Dismissed *bool `json:"dismissed"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if *req.Dismissed {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
|
||||||
|
} else {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
|
||||||
|
// and returns the response and a client holding whatever cookie came back.
|
||||||
|
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signup: %v", err)
|
||||||
|
}
|
||||||
|
return resp, client
|
||||||
|
}
|
||||||
|
|
||||||
|
// invite mints a link into the default team and returns its raw token.
|
||||||
|
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
|
||||||
|
map[string]any{"role": role, "max_uses": maxUses}), &out)
|
||||||
|
if out.URL == "" {
|
||||||
|
t.Fatal("no invite URL returned")
|
||||||
|
}
|
||||||
|
// ...?invite=<token>
|
||||||
|
i := len(out.URL) - 1
|
||||||
|
for ; i >= 0 && out.URL[i] != '='; i-- {
|
||||||
|
}
|
||||||
|
return out.URL[i+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSignupMode(t *testing.T, s *ts, mode string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("set signup mode: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default is the closed door. An install that gets a public hostname before
|
||||||
|
// anybody has thought about sign-up should not be collecting accounts.
|
||||||
|
func TestSignup_InviteOnlyByDefault(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "invite_only" {
|
||||||
|
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invite carries the team and the role, so redeeming one lands somewhere
|
||||||
|
// usable rather than in an account that sees an empty queue.
|
||||||
|
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
// The form checks the link before asking for a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != true {
|
||||||
|
t.Fatalf("a fresh invite should be valid: %v", info)
|
||||||
|
}
|
||||||
|
if info["invite_team"] != "Default" {
|
||||||
|
t.Errorf("the form should name the team: %v", info["invite_team"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "newcomer", "email": "n@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.IsAdmin {
|
||||||
|
t.Error("somebody who signs up must not be an administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in already: the cookie came back with the response.
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
|
||||||
|
t.Errorf("expected membership of Default as member, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-use link is single-use, and the check is inside the transaction so
|
||||||
|
// two people redeeming the last use at once cannot both get in.
|
||||||
|
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
first, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "first", "email": "f@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
first.Body.Close()
|
||||||
|
if first.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first redemption: %d", first.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "second", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
second.Body.Close()
|
||||||
|
if second.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the link reports itself unusable before anybody types a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != false {
|
||||||
|
t.Error("a used-up invite should report itself invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking stops a link without waiting for it to expire.
|
||||||
|
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
|
||||||
|
if len(invites) != 1 {
|
||||||
|
t.Fatalf("expected one invite, got %d", len(invites))
|
||||||
|
}
|
||||||
|
id := int64(invites[0]["id"].(float64))
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
used, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "late", "email": "l@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
used.Body.Close()
|
||||||
|
if used.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open sign-up makes a team, because an account in no team sees an empty queue
|
||||||
|
// and can be paged by nobody.
|
||||||
|
func TestSignup_OpenModeMakesATeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
|
||||||
|
missing, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
missing.Body.Close()
|
||||||
|
if missing.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "team_name": "Solo",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("open sign-up: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
|
||||||
|
t.Errorf("the creator should own their new team, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the mode is an administrator's decision, and it takes effect at
|
||||||
|
// once rather than at the next restart.
|
||||||
|
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
|
||||||
|
bad.Body.Close()
|
||||||
|
if bad.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "open" {
|
||||||
|
t.Errorf("the change should be visible at once, got %v", info["mode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minting a link is configuring the team, so it is an owner's job.
|
||||||
|
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A password still has to be a password, and a taken username is still taken.
|
||||||
|
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
short, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
|
||||||
|
})
|
||||||
|
short.Body.Close()
|
||||||
|
if short.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
taken, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "admin", "email": "other@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
taken.Body.Close()
|
||||||
|
if taken.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-3
@@ -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
|
// 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
|
// with no owner would need an administrator to repair before anybody could use
|
||||||
// it, so the two happen in one transaction.
|
// it, so the two happen in one transaction.
|
||||||
@@ -463,9 +525,10 @@ func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultTeamID is the team the deprecated unauthenticated webhook routes to:
|
// defaultTeamID is the oldest team, which on an upgraded install is the
|
||||||
// the oldest one, which on an upgraded install is the "Default" team every
|
// "Default" team every pre-teams row was moved into and on a fresh one is the
|
||||||
// pre-teams row was moved into.
|
// team migration 003 creates. Bootstrap puts the first user in it, so somebody
|
||||||
|
// signing in to a new server lands somewhere rather than in no team at all.
|
||||||
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
|
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +32,19 @@ import (
|
|||||||
// tests nothing is worse than one that does not run.
|
// tests nothing is worse than one that does not run.
|
||||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||||
|
|
||||||
|
// testConfig is the environment half of the server's configuration, which the
|
||||||
|
// admin settings page renders read-only and SeedSettings seeds the editable
|
||||||
|
// half from. The durations match the defaults config.Load would produce, so a
|
||||||
|
// test that never touches the settings table behaves as a fresh install does.
|
||||||
|
func testConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
Addr: ":8080",
|
||||||
|
ArchiveAfter: 7 * 24 * time.Hour,
|
||||||
|
StaleAfter: 6 * time.Hour,
|
||||||
|
NotifyRepeat: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
|
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
|
||||||
// a path segment. Every test that does not say otherwise works inside it.
|
// a path segment. Every test that does not say otherwise works inside it.
|
||||||
const defaultTeam = "1"
|
const defaultTeam = "1"
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := db.QueryContext(r.Context(),
|
rows, err := db.QueryContext(r.Context(),
|
||||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users ORDER BY id")
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -107,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin); err != nil {
|
var disabled *int64
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
respond(w, http.StatusOK, users)
|
respond(w, http.StatusOK, users)
|
||||||
@@ -325,13 +327,15 @@ func randomToken() (raw, hash string, err error) {
|
|||||||
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
|
var disabled *int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id).
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
|
||||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Settings that an administrator can change without a redeploy, and the flag
|
||||||
|
-- that takes an account out of use without deleting it.
|
||||||
|
--
|
||||||
|
-- Three of the server's tunables were environment variables, which meant
|
||||||
|
-- changing how long an incident waits before it is paged again required editing
|
||||||
|
-- a chart, merging it, and waiting for a reconcile. They are behaviour, not
|
||||||
|
-- infrastructure, and the difference is who needs to change them and how often.
|
||||||
|
--
|
||||||
|
-- What stays in the environment: the ntfy URL and token, the database DSN, the
|
||||||
|
-- listen address and the public URL. Those are where the server is plugged in
|
||||||
|
-- rather than how it behaves, they are needed before the database is open, and
|
||||||
|
-- two of them are credentials.
|
||||||
|
--
|
||||||
|
-- Key/value rather than a column per setting. A settings table with one row and
|
||||||
|
-- a column per knob needs a migration for every new knob, and #6 and #7 will
|
||||||
|
-- both add some. The cost is that values are text and the accessor has to say
|
||||||
|
-- what type it wanted; settings.go does that in one place.
|
||||||
|
--
|
||||||
|
-- No rows are seeded here: a migration cannot read the environment. The server
|
||||||
|
-- inserts each key from its own configuration at startup, once, so an install
|
||||||
|
-- that upgrades keeps exactly the behaviour it had. See SeedSettings.
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Disabling an account rather than deleting it: the person has left, or the
|
||||||
|
-- credential is suspect, and their incidents, acknowledgements and timeline
|
||||||
|
-- entries must stay exactly where they are. Deleting a user nulls their
|
||||||
|
-- acknowledged_by and assigned_to, which quietly rewrites history.
|
||||||
|
--
|
||||||
|
-- A disabled user cannot sign in and their API keys stop working, but they are
|
||||||
|
-- still a name the timeline can show and still a member of their teams.
|
||||||
|
ALTER TABLE users ADD COLUMN disabled_at BIGINT;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Escalation: page somebody else when the first person does not answer.
|
||||||
|
--
|
||||||
|
-- This is the gap the whole multi-tenancy line of work was opened to close.
|
||||||
|
-- Until now an unacknowledged incident re-paged the same topic every
|
||||||
|
-- notify_repeat forever, which is a louder version of the same silence: if the
|
||||||
|
-- person on call is asleep, has no signal, or has left, nothing else happens.
|
||||||
|
--
|
||||||
|
-- Shape: one policy per team, an ordered list of levels, each level with a
|
||||||
|
-- timeout and a set of targets. When a level's timeout passes and the incident
|
||||||
|
-- is still triggered, the next level is paged. When the last level passes, the
|
||||||
|
-- chain repeats repeat_count times, and then the team's fallback topic is paged
|
||||||
|
-- once as the end of the line.
|
||||||
|
--
|
||||||
|
-- A team WITHOUT a policy keeps exactly today's behaviour: page the assignee,
|
||||||
|
-- then remind on the same topic. Escalation is opt-in per team, and the two
|
||||||
|
-- never both run for one incident -- see enqueueReminders.
|
||||||
|
CREATE TABLE escalation_policies (
|
||||||
|
-- One per team for now, hence the team as the key rather than an id with a
|
||||||
|
-- unique index: routing different alerts to different chains needs the
|
||||||
|
-- alert to carry something to route ON, which is a separate question.
|
||||||
|
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- How many extra times to run the whole chain after it has been walked
|
||||||
|
-- once. 0 means walk it once and stop at the fallback.
|
||||||
|
repeat_count BIGINT NOT NULL DEFAULT 0 CHECK (repeat_count >= 0 AND repeat_count <= 10),
|
||||||
|
|
||||||
|
-- Where the last page goes when every level has been tried. Per team now:
|
||||||
|
-- TERDUT_NTFY_FALLBACK_TOPIC was one topic for the whole install, which in
|
||||||
|
-- a multi-team server pages the wrong people. Empty means the chain simply
|
||||||
|
-- ends.
|
||||||
|
fallback_topic TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE escalation_levels (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES escalation_policies(team_id) ON DELETE CASCADE,
|
||||||
|
-- 1-based, dense. The API rewrites the whole ladder on every edit rather
|
||||||
|
-- than patching one rung, so there is no way to leave a gap.
|
||||||
|
position BIGINT NOT NULL,
|
||||||
|
-- How long this level has to produce an acknowledgement before the next one
|
||||||
|
-- is paged. Seconds, like every other duration in this schema.
|
||||||
|
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||||
|
|
||||||
|
UNIQUE (team_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Who a level pages. Either a named person, or whoever the team's rota says is
|
||||||
|
-- on call today -- which is the target that keeps working when the rota
|
||||||
|
-- changes and nobody remembers to edit the policy.
|
||||||
|
CREATE TABLE escalation_targets (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
level_id BIGINT NOT NULL REFERENCES escalation_levels(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('user', 'oncall')),
|
||||||
|
-- Set for kind='user', NULL for kind='oncall'.
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CHECK ((kind = 'user' AND user_id IS NOT NULL) OR (kind = 'oncall' AND user_id IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX escalation_targets_level_idx ON escalation_targets(level_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Where an incident is in its chain.
|
||||||
|
--
|
||||||
|
-- On the incident rather than in a side table: it is read on every notifier
|
||||||
|
-- tick alongside the incident's status, and one row per incident is exactly
|
||||||
|
-- what the state is.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 0 means no level has been paged yet, which is the state of every incident
|
||||||
|
-- that existed before escalation and of every incident in a team with no
|
||||||
|
-- policy. 1 is the first level.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_level BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- When the current level was entered, and therefore what its timeout is
|
||||||
|
-- measured from. NULL while escalation_level is 0.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_level_at BIGINT;
|
||||||
|
|
||||||
|
-- How many times the chain has been walked in full. Compared against the
|
||||||
|
-- policy's repeat_count.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_round BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- The notifier's escalation query: incidents still waiting, oldest level first.
|
||||||
|
CREATE INDEX incidents_escalation_idx
|
||||||
|
ON incidents(escalation_level_at)
|
||||||
|
WHERE resolved_at IS NULL AND status = 'triggered';
|
||||||
|
|
||||||
|
-- 'escalated' joins the outbox kinds: a page that went out because nobody
|
||||||
|
-- answered the last one, which is worth telling apart from the first page and
|
||||||
|
-- from a reminder when reading the timeline or debugging a delivery.
|
||||||
|
ALTER TABLE notifications DROP CONSTRAINT notifications_kind_check;
|
||||||
|
ALTER TABLE notifications ADD CONSTRAINT notifications_kind_check
|
||||||
|
CHECK (kind IN ('triggered', 'reminder', 'resolved', 'escalated'));
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Self-service sign-up, and the invite links that make it useful.
|
||||||
|
--
|
||||||
|
-- Until now the only way to get an account was for somebody who already had one
|
||||||
|
-- to create it, and the login page told people to "ask an admin". That is a
|
||||||
|
-- workable arrangement for one operator and an impossible one for a team.
|
||||||
|
--
|
||||||
|
-- An invite is a link, not an email: this server has no SMTP and adding it to
|
||||||
|
-- send one message would be a new subsystem to run, secure and monitor. The
|
||||||
|
-- person inviting sends the link however they already talk to the person they
|
||||||
|
-- are inviting.
|
||||||
|
CREATE TABLE invites (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
|
||||||
|
-- SHA-256 of the raw token, like api_keys, the integration keys and the
|
||||||
|
-- acknowledgement tokens. A leaked database hands nobody an account.
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Which team the invitee lands in, and as what. An invite always names a
|
||||||
|
-- team: an account in no team sees an empty queue and can be paged by
|
||||||
|
-- nobody, which is not a state to invite somebody into.
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
|
||||||
|
-- Invites expire. A link that works forever is a credential nobody
|
||||||
|
-- remembers issuing, sitting in a chat log.
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- Single-use by default: max_uses 1. A team onboarding six people at once
|
||||||
|
-- can raise it rather than minting six links.
|
||||||
|
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
|
||||||
|
uses BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Revoked by hand, separately from expiry, so "this link is no longer
|
||||||
|
-- wanted" and "this link timed out" stay distinguishable in the listing.
|
||||||
|
revoked_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX invites_team_idx ON invites(team_id);
|
||||||
|
|
||||||
|
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
|
||||||
|
-- did this account get here" should outlive the link that made it.
|
||||||
|
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Where a person is in the first-run checklist, so it can be resumed and
|
||||||
|
-- dismissed rather than nagging forever. One row per user, created on demand.
|
||||||
|
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
|
||||||
@@ -11,6 +11,13 @@ import "time"
|
|||||||
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
||||||
// stopped firing.
|
// stopped firing.
|
||||||
type Incident struct {
|
type Incident struct {
|
||||||
|
// EscalationLevel is which rung of its team's ladder this incident is on,
|
||||||
|
// 0 for none — either the team has no ladder, or somebody has answered.
|
||||||
|
// EscalationDueAt is when the current level runs out, so a client can say
|
||||||
|
// how long is left rather than only what already happened.
|
||||||
|
EscalationLevel int64 `json:"escalation_level"`
|
||||||
|
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
|
||||||
|
|
||||||
// TeamID is the team that owns this incident, fixed when it opens: an
|
// TeamID is the team that owns this incident, fixed when it opens: an
|
||||||
// incident never moves between teams. TeamName rides along so the combined
|
// incident never moves between teams. TeamName rides along so the combined
|
||||||
// queue can badge each row without a second request.
|
// queue can badge each row without a second request.
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type User struct {
|
|||||||
// fallback topic instead.
|
// fallback topic instead.
|
||||||
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
||||||
|
|
||||||
|
// DisabledAt is when the account was taken out of use, or nil. A disabled
|
||||||
|
// user cannot authenticate by either credential, and keeps their name on
|
||||||
|
// every acknowledgement and timeline entry they made.
|
||||||
|
DisabledAt *time.Time `json:"disabled_at,omitempty"`
|
||||||
|
|
||||||
// IsAdmin is the system administrator flag: managing users and API keys.
|
// IsAdmin is the system administrator flag: managing users and API keys.
|
||||||
// Not omitempty — a client has to be able to tell "false" from "this server
|
// Not omitempty — a client has to be able to tell "false" from "this server
|
||||||
// is too old to have the field", and the web UI decides what to show from
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
|
|||||||
+244
-2
@@ -31,6 +31,14 @@
|
|||||||
--snooze: #6b5bd2;
|
--snooze: #6b5bd2;
|
||||||
--snooze-soft: #efedfb;
|
--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: 10px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||||
@@ -72,6 +80,11 @@
|
|||||||
--snooze: #a89bff;
|
--snooze: #a89bff;
|
||||||
--snooze-soft: #262245;
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
}
|
}
|
||||||
@@ -199,7 +212,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
/* Bottom tab bar on phones. */
|
/* Bottom tab bar on phones. */
|
||||||
.nav {
|
.nav {
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
/* 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));
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
padding-bottom: var(--safe-bottom);
|
padding-bottom: var(--safe-bottom);
|
||||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
@@ -212,8 +230,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||||
|
/* min-width lets a column shrink below its label's natural width, which is
|
||||||
|
what stops six tabs widening the bar past the screen. */
|
||||||
|
min-width: 0; padding: 0 2px;
|
||||||
}
|
}
|
||||||
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
.nav-label {
|
||||||
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.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-link[aria-current="page"] { color: var(--accent); }
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||||
@@ -630,3 +665,210 @@ kbd {
|
|||||||
.toast, .app.detail-open ~ .toast { bottom: 24px; }
|
.toast, .app.detail-open ~ .toast { bottom: 24px; }
|
||||||
.only-desktop { display: block; }
|
.only-desktop { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- admin ---------------------------------------------------------------
|
||||||
|
The admin page is three tables of things you act on, so it needs table
|
||||||
|
styling the rest of the app never did: the queue is a list of links and the
|
||||||
|
account page is a form. */
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.admin-table th {
|
||||||
|
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.admin-table tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
|
||||||
|
/* A disabled account stays readable — it is still the name on old
|
||||||
|
acknowledgements — but should not look like a working one. */
|
||||||
|
.disabled-row td { opacity: 0.55; }
|
||||||
|
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
||||||
|
|
||||||
|
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.inline-form input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
|
||||||
|
.admin-settings .setting-unit { max-width: 8em; }
|
||||||
|
.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
|
||||||
|
gets a little indentation to make the levels read as an order. */
|
||||||
|
.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; }
|
||||||
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||||
|
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
|
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* An integration key is shown exactly once, so it should look like something
|
||||||
|
to act on rather than another row of text. */
|
||||||
|
.key-panel {
|
||||||
|
margin-top: 12px; padding: 12px;
|
||||||
|
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.key-panel pre {
|
||||||
|
overflow-x: auto; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 8px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.key-url code { word-break: break-all; }
|
||||||
|
|
||||||
|
/* --- onboarding checklist ------------------------------------------------
|
||||||
|
Sits above the queue until it is finished or hidden. Deliberately plain:
|
||||||
|
it is a list of things to do, not a celebration. */
|
||||||
|
.onboarding { border-left: 3px solid var(--accent); }
|
||||||
|
.onboarding-head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.onboarding-head h2 { flex: 1; margin: 0; }
|
||||||
|
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
|
||||||
|
.checklist .step p { margin: 2px 0 0; }
|
||||||
|
.step-mark {
|
||||||
|
flex: none; width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-strong); color: var(--accent);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||||
|
}
|
||||||
|
.step.done .step-mark { border-color: var(--accent); }
|
||||||
|
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.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; }
|
||||||
|
|||||||
@@ -37,6 +37,37 @@
|
|||||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||||
|
<p class="login-hint" id="signup-link" hidden>
|
||||||
|
No account? <a href="/signup">Create one</a>.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||||
|
what the server allows: an invite link, or open sign-up. -->
|
||||||
|
<form id="signup-form" class="login-card" autocomplete="on" hidden>
|
||||||
|
<div class="login-brand">
|
||||||
|
<img src="/icon.svg" alt="" width="40" height="40">
|
||||||
|
<h1>terdut</h1>
|
||||||
|
</div>
|
||||||
|
<p class="signup-intro" id="signup-intro"></p>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input name="email" type="email" autocomplete="email" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
|
||||||
|
</label>
|
||||||
|
<label id="signup-team-label" hidden>
|
||||||
|
<span>Team name</span>
|
||||||
|
<input name="team_name" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<p class="form-error" role="alert" hidden></p>
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">Create account</button>
|
||||||
|
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -46,20 +77,31 @@
|
|||||||
<img src="/icon.svg" alt="" width="28" height="28">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/" data-section="queue">
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
<span class="nav-badge" data-badge hidden></span>
|
<span class="nav-badge" data-badge hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/oncall" data-section="oncall">
|
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||||
<span class="nav-label">On-call</span>
|
<span class="nav-label">On-call</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/alerts" data-section="alerts">
|
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
|
||||||
<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>
|
<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>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/more" data-section="more">
|
<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>
|
||||||
|
</a>
|
||||||
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
|
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||||
|
<span class="nav-label">Admin</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||||
<span class="nav-label">Account</span>
|
<span class="nav-label">Account</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -80,6 +122,14 @@
|
|||||||
|
|
||||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
<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-alerts" class="view view-page" data-view="alerts" 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>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function render() {
|
|||||||
h('div', { class: 'account-name', text: user.username }),
|
h('div', { class: 'account-name', text: user.username }),
|
||||||
h('div', { class: 'account-email', text: user.email }))),
|
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' })),
|
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||||
passwordForm(user, hasPassword),
|
passwordForm(user, hasPassword),
|
||||||
|
|
||||||
@@ -32,10 +35,91 @@ function render() {
|
|||||||
|
|
||||||
h('div', { class: 'page-head' }),
|
h('div', { class: 'page-head' }),
|
||||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
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.' }),
|
h('p', { class: 'foot-note', text: 'Statistics 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) {
|
function passwordForm(user, hasPassword) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// 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, menuCard } from './ui.js';
|
||||||
|
import { state, myID } from './state.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-admin');
|
||||||
|
|
||||||
|
// 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(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = await load();
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
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' },
|
||||||
|
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
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}` }),
|
||||||
|
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.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function teamsCard() {
|
||||||
|
const rows = data.teams.map((t) =>
|
||||||
|
h('tr', {},
|
||||||
|
// 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) }),
|
||||||
|
));
|
||||||
|
|
||||||
|
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('tbody', {}, rows)),
|
||||||
|
newTeamForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTeamForm() {
|
||||||
|
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
|
||||||
|
const form = h('form', { class: 'inline-form' }, name,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.createTeam(name.value.trim());
|
||||||
|
name.value = '';
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- users -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function usersCard() {
|
||||||
|
const rows = data.users.map((u) => {
|
||||||
|
const self = u.id === myID();
|
||||||
|
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||||
|
h('td', {},
|
||||||
|
// 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 }),
|
||||||
|
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
|
||||||
|
h('td', {},
|
||||||
|
// Neither action is offered for your own account: the server refuses
|
||||||
|
// both, and an enabled-looking button that always fails is worse than
|
||||||
|
// no button.
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn-sm',
|
||||||
|
type: 'button',
|
||||||
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
onclick: () => setAdmin(u, !u.is_admin),
|
||||||
|
}),
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn-sm danger',
|
||||||
|
type: 'button',
|
||||||
|
text: u.disabled_at ? 'Enable' : 'Disable',
|
||||||
|
onclick: () => setDisabled(u, !u.disabled_at),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
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. ',
|
||||||
|
'Open a name for their teams, their password and the rest.'),
|
||||||
|
h('table', { class: 'admin-table' },
|
||||||
|
h('thead', {}, h('tr', {},
|
||||||
|
h('th', { text: 'User' }),
|
||||||
|
h('th', { text: 'Email' }),
|
||||||
|
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.' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdmin(user, next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Make ${user.username} an administrator?`,
|
||||||
|
text: 'They will be able to create and delete users, and grant this to others.',
|
||||||
|
confirmLabel: 'Make admin',
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.setUserAdmin(user.id, next);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDisabled(user, next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Disable ${user.username}?`,
|
||||||
|
text: 'They cannot sign in and their API keys stop working. Their history stays.',
|
||||||
|
confirmLabel: 'Disable',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.setUserDisabled(user.id, next);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- settings --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Seconds are what the API speaks; people think in minutes and hours. The two
|
||||||
|
// are converted here rather than in the server, which should keep exactly one
|
||||||
|
// unit.
|
||||||
|
const UNITS = [
|
||||||
|
{ label: 'minutes', seconds: 60 },
|
||||||
|
{ label: 'hours', seconds: 3600 },
|
||||||
|
{ label: 'days', seconds: 86400 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function bestUnit(seconds) {
|
||||||
|
for (const u of [...UNITS].reverse()) {
|
||||||
|
if (seconds > 0 && seconds % u.seconds === 0) return u;
|
||||||
|
}
|
||||||
|
return UNITS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsCard() {
|
||||||
|
const editable = data.settings.editable || {};
|
||||||
|
const inputs = new Map();
|
||||||
|
|
||||||
|
const rows = Object.entries(editable).map(([key, s]) => {
|
||||||
|
const unit = bestUnit(s.seconds);
|
||||||
|
const value = h('input', {
|
||||||
|
type: 'number',
|
||||||
|
min: '0',
|
||||||
|
value: String(Math.round(s.seconds / unit.seconds)),
|
||||||
|
class: 'setting-value',
|
||||||
|
});
|
||||||
|
const select = h('select', { class: 'setting-unit' },
|
||||||
|
...UNITS.map((u) => h('option', {
|
||||||
|
value: String(u.seconds),
|
||||||
|
text: u.label,
|
||||||
|
selected: u.seconds === unit.seconds,
|
||||||
|
})));
|
||||||
|
inputs.set(key, () => Number(value.value) * Number(select.value));
|
||||||
|
|
||||||
|
return h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })),
|
||||||
|
h('td', { class: 'muted small', text: s.description }),
|
||||||
|
h('td', {}, value, select),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = h('form', { class: 'admin-settings' },
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Save settings' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
const body = {};
|
||||||
|
for (const [key, read] of inputs) body[key] = read();
|
||||||
|
try {
|
||||||
|
await api.setAdminSettings(body);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = Object.entries(data.settings.from_env || {}).map(([k, v]) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('code', { text: k })),
|
||||||
|
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Settings' }),
|
||||||
|
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
|
||||||
|
form,
|
||||||
|
h('h3', { text: 'From the environment' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Where the server is plugged in, rather than how it behaves. These are set ',
|
||||||
|
'in the deployment and are read-only here. Credentials are never shown.'),
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, env)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
// users
|
||||||
export const users = () => call('GET', '/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
|
// incidents
|
||||||
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
||||||
export const incident = (id) => call('GET', `/incidents/${id}`);
|
export const incident = (id) => call('GET', `/incidents/${id}`);
|
||||||
@@ -87,7 +96,63 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
|
|||||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||||
|
|
||||||
// schedule
|
// schedule
|
||||||
|
// Sign-up, both halves unauthenticated: the caller has no account yet.
|
||||||
|
export const signupInfo = (invite) =>
|
||||||
|
call('GET', '/signup', { query: invite ? { invite } : {} });
|
||||||
|
export const signup = (body) => call('POST', '/signup', { body });
|
||||||
|
|
||||||
|
export const invites = (id) => call('GET', `/teams/${id}/invites`);
|
||||||
|
export const createInvite = (id, role, maxUses) =>
|
||||||
|
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
|
||||||
|
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
|
||||||
|
|
||||||
|
export const testNotification = () => call('POST', '/me/notify/test');
|
||||||
|
export const dismissOnboarding = (dismissed) =>
|
||||||
|
call('PUT', '/me/onboarding', { body: { dismissed } });
|
||||||
|
|
||||||
export const teams = () => call('GET', '/teams');
|
export const teams = () => call('GET', '/teams');
|
||||||
|
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
||||||
|
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
||||||
|
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
|
||||||
|
|
||||||
|
// A team's own settings. Every write is owner-only and every read is
|
||||||
|
// member-only; the server answers 403 and 404 respectively, so the UI shows
|
||||||
|
// what the role allows rather than guarding it.
|
||||||
|
export const teamMembers = (id) => call('GET', `/teams/${id}/members`);
|
||||||
|
export const addTeamMember = (id, userID, role) =>
|
||||||
|
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
||||||
|
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
||||||
|
|
||||||
|
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
||||||
|
export const createIntegration = (id, name) =>
|
||||||
|
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
||||||
|
export const deleteIntegration = (id, integrationID) =>
|
||||||
|
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
||||||
|
|
||||||
|
export const deadman = (id) => call('GET', `/teams/${id}/deadman`);
|
||||||
|
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body });
|
||||||
|
|
||||||
|
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
||||||
|
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
||||||
|
|
||||||
|
export const assignSchedule = (id, userID, dates, replace = false) =>
|
||||||
|
call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } });
|
||||||
|
export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`);
|
||||||
|
|
||||||
|
// 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) =>
|
export const schedule = (teamID, from, to) =>
|
||||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||||
|
|
||||||
|
|||||||
@@ -9,25 +9,59 @@ import * as incident from './incident.js';
|
|||||||
import * as oncall from './oncall.js';
|
import * as oncall from './oncall.js';
|
||||||
import * as alerts from './alerts.js';
|
import * as alerts from './alerts.js';
|
||||||
import * as account from './account.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);
|
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 = {
|
const SECTIONS = {
|
||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
|
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 },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseRoute(pathname) {
|
function parseRoute(pathname) {
|
||||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
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, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
|
// 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 === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
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);
|
let route = parseRoute(location.pathname);
|
||||||
// How many in-app navigations deep we are, so Back can use the browser's
|
// 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.
|
// history when there is somewhere to go back to, and the queue otherwise.
|
||||||
@@ -60,13 +94,16 @@ function render() {
|
|||||||
route = parseRoute(location.pathname);
|
route = parseRoute(location.pathname);
|
||||||
const app = $('app');
|
const app = $('app');
|
||||||
|
|
||||||
for (const [name, s] of Object.entries(SECTIONS)) {
|
for (const name of Object.keys(SECTIONS)) {
|
||||||
const el = $(`view-${name}`);
|
const el = $(`view-${name}`);
|
||||||
el.hidden = name !== route.section;
|
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')) {
|
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');
|
else link.removeAttribute('aria-current');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +118,16 @@ function render() {
|
|||||||
incident.show(route.incident);
|
incident.show(route.incident);
|
||||||
} else {
|
} else {
|
||||||
incident.show(null);
|
incident.show(null);
|
||||||
SECTIONS[route.section].view.show();
|
SECTIONS[route.section].view.show(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
||||||
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
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();
|
updateTitle();
|
||||||
}
|
}
|
||||||
@@ -125,7 +166,7 @@ function updateBadges() {
|
|||||||
|
|
||||||
function updateTitle() {
|
function updateTitle() {
|
||||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
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`;
|
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
||||||
document.title = triggered ? `(${triggered}) ${base}` : base;
|
document.title = triggered ? `(${triggered}) ${base}` : base;
|
||||||
}
|
}
|
||||||
@@ -138,10 +179,21 @@ async function boot() {
|
|||||||
document.addEventListener('click', interceptLinks);
|
document.addEventListener('click', interceptLinks);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
|
|
||||||
|
// /signup is the one route that works without a session.
|
||||||
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||||
|
$('boot').hidden = true;
|
||||||
|
await showSignup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||||
|
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||||
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
showApp();
|
showApp();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) showLogin();
|
if (err.status === 401) showLogin();
|
||||||
@@ -154,6 +206,84 @@ function showBootError(err) {
|
|||||||
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
|
||||||
|
// server has already judged — the form says whether the link is good before
|
||||||
|
// somebody picks a password, rather than after.
|
||||||
|
async function showSignup() {
|
||||||
|
poll.stop();
|
||||||
|
ui.closeSheet(null);
|
||||||
|
reset();
|
||||||
|
$('boot').hidden = true;
|
||||||
|
$('app').hidden = true;
|
||||||
|
$('login').hidden = false;
|
||||||
|
$('login-form').hidden = true;
|
||||||
|
$('signup-form').hidden = false;
|
||||||
|
|
||||||
|
const invite = new URLSearchParams(location.search).get('invite');
|
||||||
|
const intro = $('signup-intro');
|
||||||
|
const form = $('signup-form');
|
||||||
|
const teamLabel = $('signup-team-label');
|
||||||
|
form.querySelector('.form-error').hidden = true;
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await api.signupInfo(invite);
|
||||||
|
} catch (err) {
|
||||||
|
intro.textContent = err.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite && info.invite_valid) {
|
||||||
|
intro.textContent = `You have been invited to ${info.invite_team}.`;
|
||||||
|
teamLabel.hidden = true;
|
||||||
|
form.team_name.required = false;
|
||||||
|
} else if (invite) {
|
||||||
|
// One answer for expired, revoked, used up and never existed, matching the
|
||||||
|
// server: which it was is not a stranger's business.
|
||||||
|
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
|
||||||
|
form.querySelector('button[type=submit]').disabled = true;
|
||||||
|
} else if (info.mode === 'open') {
|
||||||
|
intro.textContent = 'Create an account and a team to put your alerts in.';
|
||||||
|
teamLabel.hidden = false;
|
||||||
|
form.team_name.required = true;
|
||||||
|
} else {
|
||||||
|
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
|
||||||
|
form.querySelector('button[type=submit]').disabled = true;
|
||||||
|
}
|
||||||
|
form.username.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSignup(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const err = form.querySelector('.form-error');
|
||||||
|
const btn = form.querySelector('button[type=submit]');
|
||||||
|
err.hidden = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
state.me = await api.signup({
|
||||||
|
username: form.username.value.trim(),
|
||||||
|
email: form.email.value.trim(),
|
||||||
|
password: form.password.value,
|
||||||
|
invite: new URLSearchParams(location.search).get('invite') || undefined,
|
||||||
|
team_name: form.team_name.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
form.password.value = '';
|
||||||
|
// Signing up signs you in, so go straight to the queue rather than to a
|
||||||
|
// login form asking for the credential just chosen.
|
||||||
|
history.replaceState({ depth: 0 }, '', '/');
|
||||||
|
route = parseRoute('/');
|
||||||
|
await loadTeams();
|
||||||
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
|
showApp();
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
poll.stop();
|
poll.stop();
|
||||||
ui.closeSheet(null);
|
ui.closeSheet(null);
|
||||||
@@ -161,8 +291,15 @@ function showLogin() {
|
|||||||
$('boot').hidden = true;
|
$('boot').hidden = true;
|
||||||
$('app').hidden = true;
|
$('app').hidden = true;
|
||||||
$('login').hidden = false;
|
$('login').hidden = false;
|
||||||
|
$('signup-form').hidden = true;
|
||||||
|
$('login-form').hidden = false;
|
||||||
const form = $('login-form');
|
const form = $('login-form');
|
||||||
form.querySelector('.form-error').hidden = true;
|
form.querySelector('.form-error').hidden = true;
|
||||||
|
// Only offer the door that is open. Somebody without an invite on an
|
||||||
|
// invite-only server should be told, not sent to a form that refuses them.
|
||||||
|
api.signupInfo().then((info) => {
|
||||||
|
$('signup-link').hidden = info.mode !== 'open';
|
||||||
|
}).catch(() => {});
|
||||||
form.password.value = '';
|
form.password.value = '';
|
||||||
(form.username.value ? form.password : form.username).focus();
|
(form.username.value ? form.password : form.username).focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ function statusBadges() {
|
|||||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||||
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
||||||
}
|
}
|
||||||
|
// Where it is on the ladder, while it is still climbing. The queue shows
|
||||||
|
// what happened; this says what happens next, which is the question somebody
|
||||||
|
// looking at an unacknowledged incident actually has.
|
||||||
|
if (inc.escalation_level > 0) {
|
||||||
|
const left = inc.escalation_due_at && isFuture(inc.escalation_due_at)
|
||||||
|
? ` · next in ${until(inc.escalation_due_at)}`
|
||||||
|
: ' · next page due';
|
||||||
|
out.push(badge(`Escalating · level ${inc.escalation_level}${left}`, 'st-triggered'));
|
||||||
|
}
|
||||||
if (inc.archived_at) out.push(badge('Archived', 'plain'));
|
if (inc.archived_at) out.push(badge('Archived', 'plain'));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -115,6 +124,10 @@ function facts() {
|
|||||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||||
add('Snoozed until', when(inc.snoozed_until));
|
add('Snoozed until', when(inc.snoozed_until));
|
||||||
}
|
}
|
||||||
|
if (inc.escalation_level > 0 && inc.escalation_due_at) {
|
||||||
|
add('Escalates next', when(inc.escalation_due_at),
|
||||||
|
h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` }));
|
||||||
|
}
|
||||||
if (inc.resolved_at) {
|
if (inc.resolved_at) {
|
||||||
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
||||||
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// The first-run checklist: the four things a new install or a new person has
|
||||||
|
// to do before an alert reaches a phone.
|
||||||
|
//
|
||||||
|
// It is computed from what the server already knows rather than from stored
|
||||||
|
// progress — a topic is set or it is not, an integration exists or it does not
|
||||||
|
// — so it cannot claim a step is done when it is not, and it comes back by
|
||||||
|
// itself if somebody deletes their integration a month later.
|
||||||
|
//
|
||||||
|
// Dismissal is the one piece of state, kept per user so finishing on a laptop
|
||||||
|
// does not leave the phone nagging.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner } from './ui.js';
|
||||||
|
import { state, currentTeam } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
import { isoDate } from './format.js';
|
||||||
|
|
||||||
|
let steps = null;
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
let testResult = null;
|
||||||
|
|
||||||
|
// done() is deliberately a question about the world, not a flag: each step asks
|
||||||
|
// the data whether it happened.
|
||||||
|
export async function load() {
|
||||||
|
const team = currentTeam();
|
||||||
|
if (!team) {
|
||||||
|
steps = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [schedule, integrations, alerts] = await Promise.all([
|
||||||
|
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
|
||||||
|
api.integrations(team.id),
|
||||||
|
api.alerts({ limit: 1 }),
|
||||||
|
]);
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
id: 'topic',
|
||||||
|
title: 'Set where your pages go',
|
||||||
|
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
|
||||||
|
done: Boolean(state.me?.user?.ntfy_topic),
|
||||||
|
action: { label: 'Account', go: '/more' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rota',
|
||||||
|
title: 'Put somebody on call',
|
||||||
|
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
|
||||||
|
done: (schedule || []).length > 0,
|
||||||
|
action: { label: 'Team', go: '/team' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integration',
|
||||||
|
title: 'Create an alert source',
|
||||||
|
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
|
||||||
|
done: (integrations || []).length > 0,
|
||||||
|
action: { label: 'Team', go: '/team' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert',
|
||||||
|
title: 'Send a test alert',
|
||||||
|
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
|
||||||
|
done: (alerts || []).length > 0,
|
||||||
|
action: { label: 'How', go: '/team' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visible reports whether there is anything worth showing: something undone,
|
||||||
|
// and not dismissed.
|
||||||
|
export function visible() {
|
||||||
|
if (!steps || state.me?.onboarding_dismissed) return false;
|
||||||
|
return steps.some((s) => !s.done);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function card() {
|
||||||
|
if (!visible()) return null;
|
||||||
|
const remaining = steps.filter((s) => !s.done).length;
|
||||||
|
|
||||||
|
return h('div', { class: 'card onboarding' },
|
||||||
|
h('div', { class: 'onboarding-head' },
|
||||||
|
h('h2', { text: 'Finish setting up' }),
|
||||||
|
h('span', { class: 'muted small', text: `${remaining} left` }),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Hide',
|
||||||
|
title: 'Hide this checklist for good',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await api.dismissOnboarding(true);
|
||||||
|
if (state.me) state.me.onboarding_dismissed = true;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
error && h('p', { class: 'load-error', text: error }),
|
||||||
|
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
|
||||||
|
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepRow(step) {
|
||||||
|
return h('li', { class: step.done ? 'step done' : 'step' },
|
||||||
|
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
|
||||||
|
h('div', {},
|
||||||
|
h('strong', { text: step.title }),
|
||||||
|
h('p', { class: 'muted small', text: step.text }),
|
||||||
|
!step.done && h('div', { class: 'step-actions' },
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: step.action.label,
|
||||||
|
onclick: () => navigate(step.action.go),
|
||||||
|
}),
|
||||||
|
// The topic step is the only one this page can finish by itself, and
|
||||||
|
// the only proof that matters is a phone buzzing.
|
||||||
|
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Send a test push',
|
||||||
|
disabled: busy,
|
||||||
|
onclick: sendTest,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest() {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
|
||||||
|
} catch (err) {
|
||||||
|
testResult = { ok: false, text: err.message };
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queue owns the card's place on the page, so ask it to redraw rather than
|
||||||
|
// reaching into its list.
|
||||||
|
let rerender = () => {};
|
||||||
|
export function onRerender(fn) {
|
||||||
|
rerender = fn;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as api from './api.js';
|
|||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID } from './state.js';
|
||||||
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
||||||
@@ -25,6 +26,8 @@ const EMPTY = {
|
|||||||
archived: ['Nothing archived', ''],
|
archived: ['Nothing archived', ''],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onboarding.onRerender(() => renderList());
|
||||||
|
|
||||||
let filter = loadFilter();
|
let filter = loadFilter();
|
||||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||||
let items = null; // null while loading
|
let items = null; // null while loading
|
||||||
@@ -89,6 +92,7 @@ export async function refresh({ fresh = false } = {}) {
|
|||||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||||
const result = cached ? state.open : await api.incidents(query);
|
const result = cached ? state.open : await api.incidents(query);
|
||||||
|
await onboarding.load();
|
||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
items = result;
|
items = result;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -153,20 +157,22 @@ function renderChips() {
|
|||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const el = document.getElementById('queue-list');
|
const el = document.getElementById('queue-list');
|
||||||
|
const checklist = onboarding.card();
|
||||||
if (error && !items) {
|
if (error && !items) {
|
||||||
clear(el, h('div', { class: 'load-error', text: error }));
|
clear(el, checklist, h('div', { class: 'load-error', text: error }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items) {
|
if (!items) {
|
||||||
clear(el, spinner());
|
clear(el, checklist, spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
const [title, text] = EMPTY[filter];
|
const [title, text] = EMPTY[filter];
|
||||||
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(el,
|
clear(el,
|
||||||
|
checklist,
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
items.map((inc, i) => row(inc, i)),
|
items.map((inc, i) => row(inc, i)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
// 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 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
|
||||||
|
// the controls a member cannot use, because a form that always fails is worse
|
||||||
|
// than no form, but it is not the thing enforcing anything.
|
||||||
|
|
||||||
|
import * as api from './api.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;
|
||||||
|
// 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(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTeam() {
|
||||||
|
const teams = state.teams || [];
|
||||||
|
return teams.find((t) => t.id === teamID) || currentTeam();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const team = selectedTeam();
|
||||||
|
if (!team) {
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teamID = team.id;
|
||||||
|
try {
|
||||||
|
data = { team, ...(await load(team.id)) };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!data) {
|
||||||
|
clear(view(), error
|
||||||
|
? h('div', { class: 'load-error', text: error })
|
||||||
|
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(view(),
|
||||||
|
subnav(),
|
||||||
|
teamPicker(),
|
||||||
|
// 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.' })),
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
const select = h('select', { class: 'team-picker' },
|
||||||
|
...state.teams.map((t) => h('option', {
|
||||||
|
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||||||
|
})));
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
teamID = Number(select.value);
|
||||||
|
data = null;
|
||||||
|
freshKey = null;
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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();
|
||||||
|
// 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' });
|
||||||
|
|
||||||
|
const form = h('form', { class: 'stacked-form' },
|
||||||
|
h('label', {}, 'Who ', who),
|
||||||
|
h('label', {}, 'From ', from),
|
||||||
|
h('label', {}, 'Days ', days),
|
||||||
|
// Taking a day somebody else holds has to be asked for, the same rule the
|
||||||
|
// API enforces: a plain assignment that silently moved a shift would move
|
||||||
|
// who gets paged without telling either of them.
|
||||||
|
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const start = new Date(from.value + 'T00:00:00Z');
|
||||||
|
const dates = [];
|
||||||
|
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
|
||||||
|
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberSelect(selected) {
|
||||||
|
return h('select', {},
|
||||||
|
...(data.members || []).map((m) => h('option', {
|
||||||
|
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- escalation ------------------------------------------------------------
|
||||||
|
|
||||||
|
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||||||
|
// it wholesale: the levels are an order, and patching one rung would leave the
|
||||||
|
// numbering of the others undecided.
|
||||||
|
let draft = null;
|
||||||
|
|
||||||
|
function escalationCard() {
|
||||||
|
const esc = data.escalation;
|
||||||
|
if (!draft) {
|
||||||
|
draft = {
|
||||||
|
repeat_count: esc.repeat_count || 0,
|
||||||
|
fallback_topic: esc.fallback_topic || '',
|
||||||
|
levels: (esc.levels || []).map((l) => ({
|
||||||
|
timeout_seconds: l.timeout_seconds,
|
||||||
|
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = [];
|
||||||
|
if (!draft.levels.length) {
|
||||||
|
body.push(h('p', { class: 'muted' },
|
||||||
|
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||||
|
'reminder interval and nobody else is woken.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.levels.forEach((level, i) => {
|
||||||
|
body.push(h('div', { class: 'ladder-level' },
|
||||||
|
h('div', { class: 'ladder-head' },
|
||||||
|
h('strong', { text: `Level ${i + 1}` }),
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
onclick: () => { draft.levels.splice(i, 1); render(); },
|
||||||
|
})),
|
||||||
|
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||||||
|
level.timeout_seconds = secs;
|
||||||
|
}), ' before the next level'),
|
||||||
|
h('div', { class: 'ladder-targets' },
|
||||||
|
...level.targets.map((t, ti) => targetRow(level, t, ti)),
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: '+ target',
|
||||||
|
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isOwner()) {
|
||||||
|
body.push(h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: '+ level',
|
||||||
|
onclick: () => {
|
||||||
|
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const repeat = h('input', {
|
||||||
|
type: 'number', min: '0', max: '10', class: 'setting-value',
|
||||||
|
value: String(draft.repeat_count),
|
||||||
|
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||||||
|
});
|
||||||
|
const fallback = h('input', {
|
||||||
|
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||||
|
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||||
|
});
|
||||||
|
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||||||
|
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||||||
|
body.push(h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Save ladder',
|
||||||
|
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Escalation' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||||
|
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||||
|
...body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetRow(level, target, index) {
|
||||||
|
const kind = h('select', {},
|
||||||
|
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
|
||||||
|
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
|
||||||
|
kind.addEventListener('change', () => {
|
||||||
|
target.kind = kind.value;
|
||||||
|
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
const who = target.kind === 'user'
|
||||||
|
? memberSelect(target.user_id)
|
||||||
|
: null;
|
||||||
|
if (who) {
|
||||||
|
who.addEventListener('change', () => { target.user_id = Number(who.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', { class: 'target-row' }, kind, who,
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: '×',
|
||||||
|
title: 'Remove this target',
|
||||||
|
onclick: () => { level.targets.splice(index, 1); render(); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesInput(seconds, onChange) {
|
||||||
|
const input = h('input', {
|
||||||
|
type: 'number', min: '1', class: 'setting-value',
|
||||||
|
value: String(Math.max(1, Math.round(seconds / 60))),
|
||||||
|
oninput: (e) => onChange(Number(e.target.value) * 60),
|
||||||
|
});
|
||||||
|
return h('span', {}, input, ' minutes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- integrations ----------------------------------------------------------
|
||||||
|
|
||||||
|
function integrationsCard() {
|
||||||
|
const rows = (data.integrations || []).map((i) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: i.name })),
|
||||||
|
h('td', { class: 'muted small', text: i.kind }),
|
||||||
|
h('td', { class: 'muted small', text: i.last_used_at ? 'in use' : 'never used' }),
|
||||||
|
h('td', {}, isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||||
|
onclick: async () => {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: `Revoke ${i.name}?`,
|
||||||
|
text: 'Anything posting with this key stops delivering immediately.',
|
||||||
|
confirmLabel: 'Revoke',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
act(() => api.deleteIntegration(teamID, i.id));
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Alert sources' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Alerts arrive on an integration key, which says both that the sender may ',
|
||||||
|
'post and which team the alerts belong to.'),
|
||||||
|
rows.length
|
||||||
|
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
||||||
|
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||||||
|
freshKey && newKeyPanel(),
|
||||||
|
isOwner() && !freshKey && newIntegrationForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is returned exactly once. Say so, show it large, and give the
|
||||||
|
// Alertmanager snippet with it already in place — the next thing anybody does
|
||||||
|
// with it is paste it into a config.
|
||||||
|
function newKeyPanel() {
|
||||||
|
const url = freshKey.url || `${location.origin}/api/integrations/${freshKey.key}/alertmanager`;
|
||||||
|
const snippet = `receivers:
|
||||||
|
- name: terdut
|
||||||
|
webhook_configs:
|
||||||
|
- url: ${url}
|
||||||
|
send_resolved: true`;
|
||||||
|
|
||||||
|
return h('div', { class: 'key-panel' },
|
||||||
|
h('strong', { text: 'Copy this now — it is not shown again.' }),
|
||||||
|
h('pre', { class: 'key-url' }, h('code', { text: url })),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Copy URL',
|
||||||
|
onclick: () => navigator.clipboard?.writeText(url),
|
||||||
|
}),
|
||||||
|
h('p', { class: 'muted small', text: 'Alertmanager receiver:' }),
|
||||||
|
h('pre', {}, h('code', { text: snippet })),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Done',
|
||||||
|
onclick: () => { freshKey = null; render(); },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newIntegrationForm() {
|
||||||
|
const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true });
|
||||||
|
const form = h('form', { class: 'inline-form' }, name,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
freshKey = await api.createIntegration(teamID, name.value.trim());
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dead man's switches ---------------------------------------------------
|
||||||
|
|
||||||
|
function deadmanCard() {
|
||||||
|
const d = data.deadman || {};
|
||||||
|
const matchers = h('input', {
|
||||||
|
type: 'text', value: d.matchers || '', placeholder: 'alertname=Watchdog',
|
||||||
|
class: 'wide',
|
||||||
|
});
|
||||||
|
const timeout = h('input', {
|
||||||
|
type: 'number', min: '0', class: 'setting-value',
|
||||||
|
value: String(Math.round((d.timeout_seconds || 0) / 60)),
|
||||||
|
});
|
||||||
|
const severity = h('select', {},
|
||||||
|
...['critical', 'error', 'warning', 'info'].map((s) =>
|
||||||
|
h('option', { value: s, text: s, selected: (d.severity || 'critical') === s })));
|
||||||
|
|
||||||
|
const form = h('form', { class: 'stacked-form' },
|
||||||
|
h('label', {}, 'Heartbeat alerts ', matchers),
|
||||||
|
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
|
||||||
|
h('label', {}, 'Open the incident at severity ', severity),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Save switches' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.setDeadman(teamID, {
|
||||||
|
matchers: matchers.value.trim(),
|
||||||
|
timeout_seconds: Number(timeout.value) * 60,
|
||||||
|
severity: severity.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Dead man’s switches' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||||||
|
'quiet for longer than the timeout opens an incident. ',
|
||||||
|
h('code', { text: 'alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat' }),
|
||||||
|
' — semicolons separate switches, commas separate conditions, and every ',
|
||||||
|
'switch must name an alertname. Leave empty to watch nothing.'),
|
||||||
|
isOwner() ? form : h('p', { class: 'muted', text: d.matchers || 'Nothing watched.' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function membersCard() {
|
||||||
|
const rows = (data.members || []).map((m) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: m.username })),
|
||||||
|
h('td', { class: 'muted small', text: m.role }),
|
||||||
|
h('td', {}, isOwner() && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button',
|
||||||
|
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
onclick: () => act(() =>
|
||||||
|
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||||
|
}), isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
const inTeam = new Set((data.members || []).map((m) => m.user_id));
|
||||||
|
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' }),
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
isOwner() && candidates.length > 0 && form,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// act runs a write and reloads. Errors are shown rather than thrown away: a
|
||||||
|
// 409 from the last-owner guard or the schedule's conflict rule is the server
|
||||||
|
// explaining itself, and the reader needs to see it.
|
||||||
|
async function act(fn, { resetDraft = false } = {}) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
error = null;
|
||||||
|
if (resetDraft) draft = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
if (!resetDraft) draft = null;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
@@ -160,6 +160,18 @@ export function labelChip(k, v) {
|
|||||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: 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) {
|
export function emptyState(title, text, iconName) {
|
||||||
return h('div', { class: 'empty' },
|
return h('div', { class: 'empty' },
|
||||||
iconName && icon(iconName),
|
iconName && icon(iconName),
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
//go:build migrate
|
|
||||||
|
|
||||||
// Command sqlite-to-postgres copies a terdut SQLite database into a freshly
|
|
||||||
// migrated Postgres one. It exists for exactly one upgrade — the one that moved
|
|
||||||
// this server off SQLite — and should be deleted once the installs that need it
|
|
||||||
// have run it. The modernc.org/sqlite dependency goes with it.
|
|
||||||
//
|
|
||||||
// Build-tagged so the dependency stays out of the server binary and out of a
|
|
||||||
// plain `go build ./...`:
|
|
||||||
//
|
|
||||||
// go run -tags migrate ./scripts/sqlite-to-postgres.go \
|
|
||||||
// -sqlite /path/to/terdut.db \
|
|
||||||
// -dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
|
|
||||||
//
|
|
||||||
// The Postgres side must already have the schema: start the new server once
|
|
||||||
// against an empty database, let it migrate, stop it, then run this. The copy
|
|
||||||
// refuses to touch a database that already has rows, so a second run cannot
|
|
||||||
// double-insert.
|
|
||||||
//
|
|
||||||
// Ids are preserved, which is what keeps every foreign key — incident_alerts,
|
|
||||||
// incident_events, notifications, the ack tokens — pointing at the same rows it
|
|
||||||
// pointed at before. The identity sequences are moved past the copied ids at the
|
|
||||||
// end, so the first row the server writes afterwards does not collide.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tables are copied parents first: every foreign key points at a table earlier
|
|
||||||
// in this list.
|
|
||||||
var tables = []struct {
|
|
||||||
name string
|
|
||||||
columns []string
|
|
||||||
// jsonb marks columns that were TEXT in SQLite and are jsonb in Postgres,
|
|
||||||
// so the insert can cast them.
|
|
||||||
jsonb []string
|
|
||||||
// sequence is the identity sequence to advance afterwards, empty when the
|
|
||||||
// table has no generated id.
|
|
||||||
sequence string
|
|
||||||
}{
|
|
||||||
{name: "users", columns: []string{"id", "username", "email", "created_at", "ntfy_topic", "password_hash"}, sequence: "users_id_seq"},
|
|
||||||
{name: "api_keys", columns: []string{"id", "user_id", "key_hash", "name", "created_at", "last_used_at"}, sequence: "api_keys_id_seq"},
|
|
||||||
{name: "sessions", columns: []string{"id", "token_hash", "user_id", "created_at", "last_seen_at", "expires_at", "user_agent"}, sequence: "sessions_id_seq"},
|
|
||||||
{name: "alerts", columns: []string{"id", "fingerprint", "name", "status", "labels", "annotations", "starts_at", "ends_at", "generator_url", "received_at", "archived_at", "resolution_source"}, jsonb: []string{"labels", "annotations"}, sequence: "alerts_id_seq"},
|
|
||||||
{name: "schedule_entries", columns: []string{"id", "user_id", "date", "created_at"}, sequence: "schedule_entries_id_seq"},
|
|
||||||
{name: "incidents", columns: []string{"id", "group_key", "title", "group_labels", "status", "severity", "triggered_at", "acknowledged_by", "acknowledged_at", "assigned_to", "snoozed_until", "resolved_at", "resolution_source", "archived_at"}, jsonb: []string{"group_labels"}, sequence: "incidents_id_seq"},
|
|
||||||
{name: "incident_alerts", columns: []string{"incident_id", "alert_id", "added_at"}},
|
|
||||||
{name: "incident_events", columns: []string{"id", "incident_id", "type", "user_id", "alert_id", "detail", "created_at"}, sequence: "incident_events_id_seq"},
|
|
||||||
{name: "notifications", columns: []string{"id", "incident_id", "user_id", "topic", "kind", "created_at", "send_after", "attempts", "sent_at", "last_error"}, sequence: "notifications_id_seq"},
|
|
||||||
{name: "incident_ack_tokens", columns: []string{"token_hash", "incident_id", "user_id", "created_at", "expires_at"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
sqlitePath := flag.String("sqlite", "", "path to the existing terdut SQLite database")
|
|
||||||
dsn := flag.String("dsn", "", "Postgres DSN of the migrated, empty database")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *sqlitePath == "" || *dsn == "" {
|
|
||||||
log.Fatal("both -sqlite and -dsn are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := sql.Open("sqlite", *sqlitePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst, err := sql.Open("pgx", *dsn)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("open postgres: %v", err)
|
|
||||||
}
|
|
||||||
defer dst.Close()
|
|
||||||
|
|
||||||
if err := dst.Ping(); err != nil {
|
|
||||||
log.Fatalf("ping postgres: %v", err)
|
|
||||||
}
|
|
||||||
if err := assertEmpty(dst); err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One transaction for the whole copy: a run that dies half way leaves the
|
|
||||||
// target as it found it, rather than a partial database someone has to
|
|
||||||
// recognise as partial.
|
|
||||||
tx, err := dst.Begin()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("begin: %v", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback() //nolint:errcheck
|
|
||||||
|
|
||||||
for _, t := range tables {
|
|
||||||
n, err := copyTable(src, tx, t.name, t.columns, t.jsonb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("copy %s: %v", t.name, err)
|
|
||||||
}
|
|
||||||
log.Printf("%-20s %d row(s)", t.name, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, t := range tables {
|
|
||||||
if t.sequence == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := advanceSequence(tx, t.sequence, t.name); err != nil {
|
|
||||||
log.Fatalf("advance %s: %v", t.sequence, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
log.Fatalf("commit: %v", err)
|
|
||||||
}
|
|
||||||
log.Print("done")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertEmpty refuses a target that already holds data, so running this twice
|
|
||||||
// cannot duplicate anything.
|
|
||||||
func assertEmpty(dst *sql.DB) error {
|
|
||||||
for _, t := range tables {
|
|
||||||
var n int64
|
|
||||||
if err := dst.QueryRow("SELECT COUNT(*) FROM " + t.name).Scan(&n); err != nil {
|
|
||||||
return fmt.Errorf("count %s (has the new server migrated this database?): %w", t.name, err)
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
return fmt.Errorf("%s already has %d row(s): the target must be empty", t.name, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyTable(src *sql.DB, tx *sql.Tx, table string, columns, jsonb []string) (int64, error) {
|
|
||||||
rows, err := src.Query("SELECT " + strings.Join(columns, ", ") + " FROM " + table)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
insert := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" +
|
|
||||||
strings.Join(valuePlaceholders(columns, jsonb), ", ") + ")"
|
|
||||||
|
|
||||||
stmt, err := tx.Prepare(insert)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
var copied int64
|
|
||||||
for rows.Next() {
|
|
||||||
// Scanning into any lets the SQLite driver decide each column's Go type
|
|
||||||
// and hands it straight back to pgx, which is all this needs: the column
|
|
||||||
// types match on both sides, apart from the JSON casts above.
|
|
||||||
values := make([]any, len(columns))
|
|
||||||
targets := make([]any, len(columns))
|
|
||||||
for i := range values {
|
|
||||||
targets[i] = &values[i]
|
|
||||||
}
|
|
||||||
if err := rows.Scan(targets...); err != nil {
|
|
||||||
return copied, err
|
|
||||||
}
|
|
||||||
if _, err := stmt.Exec(values...); err != nil {
|
|
||||||
return copied, fmt.Errorf("insert row %d: %w", copied+1, err)
|
|
||||||
}
|
|
||||||
copied++
|
|
||||||
}
|
|
||||||
return copied, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// valuePlaceholders numbers the placeholders, casting the columns that became
|
|
||||||
// jsonb: pgx sends a Go string as text, and Postgres will not assign text to a
|
|
||||||
// jsonb column without being told.
|
|
||||||
func valuePlaceholders(columns, jsonb []string) []string {
|
|
||||||
isJSON := make(map[string]bool, len(jsonb))
|
|
||||||
for _, c := range jsonb {
|
|
||||||
isJSON[c] = true
|
|
||||||
}
|
|
||||||
out := make([]string, len(columns))
|
|
||||||
for i, c := range columns {
|
|
||||||
out[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
if isJSON[c] {
|
|
||||||
out[i] += "::jsonb"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanceSequence puts an identity sequence past the largest copied id. Without
|
|
||||||
// it the first insert after the migration would reuse id 1.
|
|
||||||
func advanceSequence(tx *sql.Tx, sequence, table string) error {
|
|
||||||
_, err := tx.Exec(fmt.Sprintf(
|
|
||||||
`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)`,
|
|
||||||
sequence, table))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user