Compare commits

...

58 Commits

Author SHA1 Message Date
Niklas Ye e8d45f9d3d Set the chart's placeholder version to 0.24.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m55s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m0s
Release / scan-image (push) Successful in 4s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 3ee8583 and d2cdcc9, so a
tree heading for v0.24.0 doesn't say 0.23.0.
2026-09-25 23:40:55 +02:00
Niklas Ye f3918b863c List dead man's switches with their status on Team -> Switches
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.

That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.

The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.

Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.

API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
2026-09-25 23:40:55 +02:00
Niklas Ye 3ee8583f6f Set the chart's placeholder version to 0.23.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as d2cdcc9 and 71d7e18, so a
tree heading for v0.23.0 doesn't say 0.22.1.
2026-09-25 19:12:33 +02:00
Niklas Ye 591d5b8df0 Copy an incident to the clipboard as Markdown
A button in the incident header (also `y`, and "Copy incident" in the
more menu) puts everything the page knows on the clipboard, for pasting
into a chat or an agent prompt with no integration involved.

The text carries the facts, every alert with all its labels and
annotations (the page only shows summary or description), the timeline
with notes in full, and the "Seen before" resolution notes. Times are
ISO 8601 and users are named rather than "you", since relative and
first-person wording is ambiguous once pasted elsewhere.

The async clipboard API needs a secure context and this server is often
reached over plain HTTP, so it falls back to execCommand.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-09-25 19:12:33 +02:00
Niklas Ye d2cdcc9776 Set the chart's placeholder version to 0.22.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m45s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 71d7e18 and 734cd9c, so a
tree heading for v0.22.1 doesn't say 0.22.0.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 17:25:17 +02:00
Niklas Ye 8b2789b9b2 Let the filter chips wrap in the desktop incident list
The list pane is 340-420px wide and its chip row scrolled sideways with the
scrollbar hidden. That works by swipe on a phone, but a mouse has nothing to
grab, so Archived (the last chip) could not be reached on a wide screen. In
the desktop layout the row now wraps instead, and the divider between the
status and team chips is hidden there, since it would sit mid-line.

Phones keep the sideways scroll: the rule is inside the min-width: 900px
block.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 17:25:17 +02:00
Niklas Ye 71d7e1853a Set the chart's placeholder version to 0.22.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m41s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 734cd9c and 43f0044, so a
tree heading for v0.22.0 doesn't say 0.21.0.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 16:55:12 +02:00
Niklas Ye 60ebb75cd2 Show notes from similar earlier incidents
Each incident gets a signature: the alert name plus the group labels that
say what is broken, minus the ones that only say where it ran (instance,
pod, container, ...). GET /api/incidents/{id}/similar returns resolved
incidents in the same team with the same signature that have notes.

Notes can be marked as the resolution note, "what fixed it", either with a
resolution field on resolve or pinned on a note. Those lead the similar
list, show on the incident page as "Seen before", and the triggered
notification carries the latest one.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 15:42:25 +02:00
Niklas Ye 734cd9c5fd Set the chart's placeholder version to 0.21.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m52s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 3s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 43f0044 and e77f04b, so a
tree heading for v0.21.0 doesn't say 0.20.1.
2026-09-25 13:02:08 +02:00
Niklas Ye 423ed9b3a3 Add a Stats page to the web UI
Statistics used to live only in terdut-tui; the account page said so.
The page shows the same figures as the TUI's Stats tab -- incident
counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and
by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All)
that the TUI does not have. The ranges are day-granular because the
server reads from/to as whole UTC dates, so there is no 24h chip.

No server change: the page uses the existing /api/stats/* endpoints,
which already scope to the caller's teams. Charts are inline SVG and
plain elements sized from script, because the CSP forbids inline styles,
inline scripts and CDN libraries.

Removes the "statistics are in terdut-tui" notes from the account page
and the README.
2026-09-25 13:02:08 +02:00
Niklas Ye 43f004499b Set the chart's placeholder version to 0.20.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m30s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e77f04b and 429d5fd, so a
tree heading for v0.20.1 doesn't say 0.20.0.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:13 +02:00
Niklas Ye 559be6de6e Retry the first database ping instead of dying on it
Every start crashed once or twice before going healthy: kube-router
enforces this namespace's NetworkPolicy per-node, reacting to the new
pod's creation event, and the app's first connection attempt can
reach Postgres's node before that node's allow-set has been updated
with the new pod's IP. The result is "connection refused" -- an
active reject, not a timeout, which is how it was told apart from
Postgres itself not being ready (it had been up for two days in the
run that was diagnosed).

That race resolves within several seconds in practice, so Open now
retries the ping up to five times, two seconds apart, logging each
failure, before giving up with the same wrapped error as before.
Nothing else about Open's behaviour changed: a genuinely absent
database still fails, just after ~8s instead of immediately.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:05 +02:00
Niklas Ye e77f04b55e Set the chart's placeholder version to 0.20.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 20s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 429d5fd and 6a03698, so a
tree heading for v0.20.0 doesn't say 0.19.0.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 21:47:25 +02:00
Niklas Ye e536fdd2c0 Replace the mobile tab bar with a hamburger menu
Six tabs (Queue, On-call, Alerts, Team, Admin, Account) had already
outgrown the bottom bar once: 56b8191 let it auto-size its columns to
fit however many there were, but on a phone that only left each tab
55-65px wide. Squeezing further wasn't an option, so the bar is gone
on mobile and a hamburger button in the topbar opens a menu instead.

The menu reuses the sheet + menu-item pattern already used for the
snooze and assign-to actions in incident.js, rather than a new overlay
component. It lists the same sections the sidebar does, including
hiding Admin for non-admins, and the triggered-incident badge that
used to sit on the Queue tab icon now shows on the hamburger button.

Desktop (>=900px) is untouched: the sidebar is the same markup, CSS
alone hides it below 900px and shows it above, so nothing there
changed behaviourally.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 21:47:15 +02:00
Niklas Ye 429d5fdda3 Set the chart's placeholder version to 0.19.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m32s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway for the same reason as 6a03698, ee22eb0 and
7b9a337 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.19.0 that says 0.18.0 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 12:23:21 +02:00
Niklas Ye 3cdd5aee1f Give the Team tab sub-sections of its own
The Team tab was five cards stacked on one page: the rota, the escalation
ladder, the alert sources, the dead man's switches and the membership.
07914d5 split the Admin tab for three reasons, and all three were sharper
here. There was no way to link somebody to the escalation ladder, which is
the thing a team owner most often has to be talked through. There was no
way to the switches but scrolling past a month of rota -- and the rota
became a month grid in v0.18.0, which made the page taller rather than
shorter. And the poll loop refetched six endpoints every tick however
little of the page you were looking at.

Each is now a route: /team/rota, /team/members, /team/escalation,
/team/sources, /team/deadman, reached from the same strip of links the
Admin tab uses, with /team an overview. A page fetches only what it shows,
so the switches are one GET and the sources are one, where every tick used
to be six.

Three of the five fetch the member list besides their own endpoint, and
for the same reason each time: a rota entry, 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 -- who is on call today, how many members and owners,
how many ladder levels and whether a fallback follows them, how many keys
and how many never used, how many switches. That is what it is for; a strip
that already links to the five does not need a second menu that repeats it.

team.js owns the table of its six routes, as admin.js owns its four, and
app.js parses against both rather than keeping a third list to drift from
them. The table carries a title beside the label where the strip's word is
too thin to name a page on its own: "Sources" is a fine tab and a poor
browser tab, so that page titles as Alert sources and the switches keep
their apostrophe in the top bar.

menuItem left admin.js for ui.js as menuCard, since both tabs now open on
one, and its CSS went from .admin-menu* to .overview-*. That is the rename
.user-link -> .row-link was in v0.18.0, for the same reason: the class was
named after the first page that used it rather than after what it is.

The read-only notice a member sees is now on the overview only. It explains
why the controls further down are missing, and a page that is nothing but
the rota grid has no controls to explain. The team picker sits above the
strip, because it changes the subject of all five, and it drops the ladder
draft when it moves -- an unsaved edit belongs to the team it was started
in.

No server change. Extensionless paths already fall back to index.html, so
/team/rota survives a reload the way /admin/users/{id} does, and no
endpoint, payload or permission moved.

Nobody has looked at this in a browser, the caveat 07914d5 and a6fa673
carried. What is checked is the wiring, and rather more of it than last
time: every sub-page was rendered against a stub fetch and a pocket DOM,
each with exactly one aria-current and fetching only the endpoints named
above; and app.js itself was booted the same way and walked through all
seventeen URLs the app has, which resolve to one section each with the
right title -- the six new ones, the four Admin ones, both subject pages,
and /incidents/42 and /nonsense still falling to the queue. Whether six
entries scroll cleanly at phone width is not checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 12:23:11 +02:00
Niklas Ye 6a03698f65 Set the chart's placeholder version to 0.18.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m40s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 23s
Cosmetic, and done anyway for the same reason as ee22eb0, 7b9a337 and
828cf87 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.18.0 that says 0.17.0 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:51 +02:00
Niklas Ye 67d68ce058 Show the rota as a month rather than a list of dates
The Team tab printed the next thirty days as thirty rows of date, name and
a Clear button. That is a rota spelled out one day at a time, and it is the
one shape the question cannot be read in: what anybody wants from a rota is
who holds which stretch, and thirty names down a column hides a handover
between two rows that look the same. It was also the longest thing on the
page by a wide margin, so the escalation ladder and the alert sources sat
below a screen of dates.

It is a month now, Monday to Sunday, one coloured initial per day. A shift
becomes a run of one colour, which is the shape the answer actually has; a
gap becomes a hole you can see. The legend underneath says whose colour is
whose, and one line says how many days are left uncovered, counting only
from today -- an empty Tuesday last week is history, not a hole somebody
still has to fill.

Laid out like the on-call page's week, deliberately: heading and arrows
outside the card, days inside it. It is the same rota, and two pages
showing it two ways would be two things to learn.

Colours come from a person's place in the member list, so they hold still
as you page between months, and six of them repeat -- the initial inside
still tells two people apart, and a legend that has to explain nine hues is
not a legend. They are not the severity palette: nothing on a rota is
critical, and a red Thursday would read as one. --teal and --pink are new
in both themes for the two the palette was short.

The per-row Clear button had nowhere left to live, so a day opens the sheet
the app already uses for confirmations: who holds it, a picker, Assign and
Clear. That assign sends replace=true where the range form still asks
first, and the difference is the point -- 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 warn about. The range form is unchanged and folded
into a details, since filling a whole shift is what it is for; it opens on
the month above it rather than on today, so paging to March to fill March
does not hand you September.

The server is untouched. The month drawn is the month fetched -- the grid's
Monday overhang and its trailing days are real days and are fetched with
it -- so paging is one GET /api/teams/{id}/schedule per month with from and
to, where it used to be one fixed thirty-day window. No new endpoint, no
change to what the API returns, and terdut-tui is unaffected.

Nobody has looked at this in a browser either. What is checked is the
rendering: team.js's own refresh() was run against a stub fetch and a
pocket DOM for September 2026, and it produces 35 cells for a month whose
1st is a Tuesday, the right from/to on the schedule call, today marked on
the 22nd, three people in the legend with "you" on the viewer, the gap
count over a five-day hole, and -- as a member rather than an owner -- the
same grid as plain divs with no sheet and no range form. How it looks at
phone width, and whether the six colours hold up in dark mode, are not
checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:36 +02:00
Niklas Ye a6fa673e08 Give every team a page of its own
The Admin tab's team list was growing controls the way the user list did
before ac9af8e: a Rename button behind window.prompt, a Delete beside it,
and -- on the Users page, of all places -- an invite form with a team
picker in front of it. The picker was the admission that an invite is a
fact about a team rather than about the server, and a prompt() is the
wrong place to read a 409 about a name already taken.

So a team is now a subject with a page, at /admin/teams/{id}, the mirror
of /admin/users/{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 list goes back to being a list, and
the name in it is the way in.

The member list is the one thing there that needed a new endpoint.
GET /api/teams/{id}/members is requireTeamMember and answers 404 to an
administrator who is not in the team, and that stays exactly as it is:
member means membership and nothing else. Reading a team's shape is a
different question from reading its work, so it gets an endpoint of its
own under AdminOnly -- GET /api/admin/teams/{id}, returning
{"team", "members"} -- rather than an exception carved into that rule. It
is a wrapper and not a team with the members hung off it, because
"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. The query and its
ordering are copied from handleListTeamMembers so the two answers to "who
is in this team" cannot disagree.

An administrator still sees none of that team's incidents, alerts or
rota. Nothing about what the flag may do changed; it could already rename
and delete any team, and staff one it is not in.

Rename now trims what it is given, as creation has always trimmed. Before
this, " " was a legal name to rename a team to but not to create one
with, which is one rule stated twice and applied once.

Nobody has looked at this in a browser, the caveat ac9af8e and 07914d5
both carried. What is checked is the wiring: admin_test.go covers the new
endpoint for an administrator outside the team, the 404 the member-only
endpoint still gives that same administrator, the 403 for a member who is
not one, a 404 for a team that does not exist, a 400 for an id that is not
a number, and the trim; the module graph evaluates at /admin/teams/{id},
and the server serves index.html there, so a reload survives.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:13 +02:00
Niklas Ye ee22eb000c Set the chart's placeholder version to 0.17.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m31s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 3s
Cosmetic, and done anyway for the same reason as 7b9a337, 828cf87 and
8869ac8 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.17.0 that says 0.16.1 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 21:11:29 +02:00
Niklas Ye 07914d5cdb Give the Admin tab sub-sections of its own
Administration was one scrolling page with three cards on it: the teams,
the people, and the settings. There was no way to link somebody to the
settings, no way back to the top of the user list but scrolling, and the
poll loop refetched all three endpoints every tick however little of the
page you were looking at.

Each is now a route -- /admin/teams, /admin/users, /admin/settings --
reached from a strip across the top, with /admin an overview that says
how many of each there are. The three cards themselves are untouched;
they are simply rendered one at a time, so a tab fetches only what it
shows. The Users page is the exception and fetches the teams too, since
its invite form has to offer a team to invite somebody into.

The strip is ordinary links rather than chips. Chips filter what a page
already shows, here and in the queue, and these four go somewhere: the
browser's Back walks them, a reload lands where you were, and the click
is intercepted by the same handler every other link in the app uses.
The current one is marked with aria-current="page", the convention the
tab bar has used since it existed, so the state lives on the attribute
and not in a class.

admin.js owns the table of the four routes, because it also builds the
strip that links to them; app.js parses against that table rather than
keeping a second list to drift from it. Adding a fifth sub-section is
one line.

The bottom tab bar still has six items. 56b8191 made it count-agnostic
when Admin arriving pushed it past four, and the note there records that
six at 420px already leaves 55-65px each -- so the sub-sections went
inside the Admin page rather than beside it.

A person's page keeps its own route at /admin/users/{id}; its back link
now returns to the user list rather than to the top of everything.

Nobody has looked at this in a browser, the same caveat ac9af8e carried.
What is checked is the wiring: the module graph evaluates at every admin
URL, all four tabs render against live server responses with one
aria-current each and the fetches the table above describes, the
non-administrator branch still refuses without fetching, and the server
serves index.html for each new path so a reload survives. The strip's
appearance at phone and desktop width is not checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 21:11:08 +02:00
Niklas Ye 7b9a337d25 Set the chart's placeholder version to 0.16.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m5s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version
from the git tag when it packages, so neither line decides anything about
what is published; they exist to be read by somebody looking at the tree
before the tag does. A tree heading for v0.16.1 that says 0.16.0 tells that
reader something false.

Its own commit, like 828cf87, 8869ac8 and 9376105 before it, so the feature
commit's diff stays the feature.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:26 +02:00
Niklas Ye fc8b0c8d58 Let people set their own ntfy topic under Account
The first-run checklist's first step is "Set where your pages go", and its
button navigated to /more — which had no field for it. Every new user was
sent to a page that could not do the thing it sent them there for, and the
only ways to actually set a topic were curl or asking an administrator.
That has been true since the checklist shipped in v0.15.0.

Account now has a Notifications section above the password form: the topic,
prefilled and saved through the endpoint that already existed, and a Send a
test push button. The test is offered only once a topic is saved, because
it publishes what the server has stored rather than what is half-typed in
the field, and a button that silently tested the previous value would be
worse than no button.

Saving assigns the response to state.me.user, so the checklist stops asking
and the test button appears without a reload. Clearing works by saving an
empty topic: the server treats that as "no topic of their own" rather than
an error, and returns a user with ntfy_topic absent — it is omitempty — so
the form reads the cleared state from the response rather than assuming it.

The copy says the topic is a shared secret, because people reach for their
own name and it is the only thing between a stranger and their pages. Same
reason the topic stays out of an incident's timeline, which every API key
can read.

No server change: PUT /api/users/{id}/notify has been self-or-admin since
#3 and needed nothing. Only the ntfy topic is per-person — the server is
the install's one TERDUT_NTFY_URL and is not something a user picks.

Also drops a line on that page still sending people to terdut-tui for user
management, which stopped being true one release ago.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:16 +02:00
Niklas Ye 828cf87656 Set the chart's placeholder version to 0.16.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version
from the git tag when it packages, so neither line decides anything about
what is published; they exist to be read by somebody looking at the tree
before the tag does. A tree heading for v0.16.0 that says 0.15.1 tells that
reader something false.

Its own commit, like 8869ac8, 9376105 and 4e8c52c before it, so the feature
commit's diff stays the feature.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:58 +02:00
Niklas Ye ac9af8e4f5 Manage a person's account and teams from one page
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.

A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.

Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.

One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.

No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.

So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.

TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.

The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:42 +02:00
Niklas Ye 8869ac864f Set the chart's placeholder version to 0.15.1
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 10s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 3s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.15.1 that still says 0.15.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-21 11:23:56 +02:00
niklas 0677e74cf8 Merge pull request 'Let the tab bar fit however many tabs there are' (#21) from compact-nav into main
CI / chart (push) Successful in 1s
CI / test (push) Successful in 6s
CI / security (push) Successful in 12s
Reviewed-on: #21
2026-09-21 09:23:31 +00:00
Niklas Ye 56b8191a78 Let the tab bar fit however many tabs there are
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 2m33s
The bottom tab bar was grid-template-columns: repeat(4, 1fr), written when
there were four tabs. Team and Admin arrived in the last two releases and
nothing updated that number, so six items were being laid into four
columns -- which on a phone is the reported symptom, tabs that do not fit
the width.

grid-auto-flow: column with grid-auto-columns: 1fr makes the count follow
the markup instead. That also handles a case a fixed number cannot: Admin
is only rendered for an administrator, so the tab count genuinely differs
between two people looking at the same install.

Then the compactness. Each link gets min-width: 0 so a column may shrink
below its label's natural width, and the label itself ellipsises rather
than widening the bar. Under 420px the font drops to 10px, the icons to
21px and the badge shrinks to match.

No icon-only breakpoint. The arithmetic says the labels fit: six tabs on
a 320px phone give about 53px each, and the widest label, "On-call", is
about 38px at 10px. A media query that never fires is dead code, and the
ellipsis is the backstop if a future tab is named something longer.

The links gained aria-labels regardless. The icons are aria-hidden, so
the visible text was the accessible name, and it should not be the only
one.

The desktop sidebar is unaffected: it overrides display, padding and
font-size itself, so none of the phone rules reach it.

Not verified on a phone -- I cannot open a browser here, so this is the
cause identified from the CSS and the widths worked out on paper.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 11:18:30 +02:00
Niklas Ye 93761056eb Set the chart's placeholder version to 0.15.0
CI / chart (push) Successful in 4s
CI / test (push) Successful in 12s
CI / security (push) Successful in 17s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 24s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.15.0 that still says 0.14.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-21 10:55:02 +02:00
niklas a92da7dcc0 Merge pull request 'Add the sign-up page and the first-run checklist' (#20) from onboarding-ui into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #20
2026-09-21 08:54:29 +00:00
Niklas Ye b39aac36b7 Add the sign-up page and the first-run checklist
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s
Second half of #7. The API could create accounts from invite links since
the last change; this is the part somebody can actually use.

/signup is the one route that works without a session. It asks the server
what it may offer before showing anything: an invite link that is good
names the team it leads to, a link that is not says so before somebody
picks a password rather than after, and an invite-only server with no
link says that instead of presenting a form it will refuse. The login
card only offers "create one" when sign-up is open, so the door nobody
can walk through is not advertised.

Signing up signs you in and lands on the queue, because the alternative
is a form saying "now go and log in" about the credential just chosen.

The checklist is the other half. Four things have to be true before an
alert reaches a phone -- a notification topic, somebody on the rota, an
alert source, and an alert that has actually arrived -- and on a fresh
install none of them are. It sits above the queue until they are.

It is computed from the data rather than from stored progress: a topic is
set or it is not, an integration exists or it does not. That means 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. The only stored
state is the dismissal, which is per user and not per browser --
finishing on a laptop should not leave the phone nagging.

The topic step is the only one the checklist can finish itself, and the
only proof that counts is a phone buzzing, so there is a test push.
POST /api/me/notify/test publishes directly rather than through the
outbox, which requires an incident this deliberately does not have. Its
failure is the useful part: a wrong topic, a rejected token and an ntfy
that is down all look identical from the phone, which is silence, so the
error comes back to the browser instead.

Verified against a live server with a real ntfy stand-in, the whole path:
an owner mints an invite, the sign-up page reports it valid and names the
team, the invitee signs up and is signed in as a member of that team, the
checklist's four questions answer correctly on a fresh install, a test
push is refused with no topic and delivered with one -- "PAGED
terdut-owner | terdut test" -- and the dismissal survives a reload.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 10:47:15 +02:00
niklas 19f168ab7e Merge pull request 'Add self-service sign-up and invite links' (#19) from signup-invites into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #19
2026-09-21 08:42:07 +00:00
Niklas Ye d827ceedff Add self-service sign-up and invite links
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 2m30s
First half of #7. 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" -- workable for one operator, impossible for a team.

Two modes, chosen by an administrator in the settings table: invite_only,
which is the default, and open. A third domain-restricted mode was
considered and dropped, because with no email in this server there is
nothing to verify an address against and it would only check the domain
of a string somebody typed.

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 from the internet, and the failure mode of a typo in the setting
is invite_only rather than open.

An invite is a link, not an email. Adding SMTP to send one message would
be a subsystem to run, secure and monitor; the person inviting sends the
link however they already talk to the person they are inviting. A link
carries the team and the role, because an account in no team sees an
empty queue and can be paged by nobody -- that is not a state to invite
somebody into. Links are single-use by default, expire after seven days,
and can be revoked before that: a link that works forever is a credential
nobody remembers issuing, sitting in a chat log.

The uses counter is incremented inside the sign-up transaction and
guarded by `uses < max_uses`, so two people redeeming the last use at
once cannot both get in.

GET /api/signup reports the mode and whether a link is usable, so the
form can say "this link has expired" before somebody picks a password
rather than after. It gives 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.

Sign-up signs you in. The alternative is a form that says "now go and log
in", which is the same credential typed twice. login and signup now share
startSession rather than each minting a cookie.

Rate-limited per address on its own limiter, not login's: a burst of
sign-ups must not lock somebody out of logging in.

The settings table grew a second shape for this. It held only durations;
signup_mode is a word from a fixed list, so the admin endpoint now
validates everything before writing anything -- a request that sets two
settings and gets one wrong changes neither.

Still to come in #7: the sign-up and invite-redemption pages, the
first-run checklist, and the in-app integration instructions. The schema
carries onboarding_dismissed_at for the checklist already.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 09:06:48 +02:00
Niklas Ye 4e8c52c28c Set the chart's placeholder version to 0.14.0
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 12s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 2s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.14.0 that still says 0.13.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-20 21:29:05 +02:00
niklas fb927aa67b Merge pull request 'Put a team's own settings in the web UI' (#18) from team-settings-ui into main
CI / test (push) Successful in 5s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #18
2026-09-20 19:26:22 +00:00
Niklas Ye d728af53b1 Put a team's own settings in the web UI
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m19s
Closes #17. Everything a team owner configures was API-only: escalation,
integrations, dead man's switches, membership, and the rota -- which the
on-call view still described as the TUI's job, and the TUI has been broken
against this server since teams landed. Setting up the feature this whole
line of work exists for meant using curl.

A Team tab now holds all of it, one team at a time, with a picker for
somebody in more than one. An owner edits; a member sees the same page
without the controls, because the server refuses their writes anyway --
hiding a button is a courtesy to the reader, not the thing enforcing
anything.

The escalation editor holds a draft and sends the whole ladder, because
the API replaces it wholesale: the levels are an order, and patching one
rung leaves the numbering of the others undecided. Adding a level
defaults to five minutes and the rota, which is the shape almost every
ladder starts as.

An integration key is returned exactly once, so creating one opens a
panel that says so, shows the URL large with a copy button, and renders
the Alertmanager receiver snippet with the URL already in it -- the next
thing anybody does with that key is paste it into a config. The panel
stays until it is dismissed rather than disappearing on the next
re-render.

The incident view gains where an incident is on the ladder and when the
next page is due, which is the question somebody looking at an
unacknowledged incident actually has. The API carries it: the incident
payload now includes escalation_level and escalation_due_at, the latter
computed in the incident SELECT by joining the level's timeout, so a list
costs no extra queries.

Verified against a live server by making every call the page makes,
including the writes: the six reads the Team tab issues, a two-level
ladder saved and read back, an integration created and its key returned
once, three days of rota assigned, switches set, and an incident showing
level 1 with a due time five minutes out.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 21:22:24 +02:00
Niklas Ye 53e5e03f4e Set the chart's placeholder version to 0.13.0
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 7s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.13.0 that still says 0.12.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-20 20:47:52 +02:00
niklas 4d62c1130b Merge pull request 'Page the next person when nobody answers' (#16) from escalation into main
CI / chart (push) Successful in 1s
CI / test (push) Successful in 6s
CI / security (push) Successful in 12s
Reviewed-on: #16
2026-09-20 16:43:03 +00:00
Niklas Ye 3183e7e5c5 Page the next person when nobody answers
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 2m21s
Closes #6, and closes the thing this whole line of work was opened for.
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, out of signal or has left the company,
nothing else happened.

A team can now configure an ordered ladder. Each level has a timeout and
a set of targets; a target is a named person or whoever the team's rota
says is on call today. That second kind is the one that keeps working
when the rota changes and nobody remembers to edit the policy. When a
level's timeout passes with the incident still triggered, the next level
is paged; off the end the chain repeats repeat_count times and then the
team's fallback topic is paged once. The incident stays open throughout,
because running out of people to wake is not somebody answering.

Escalation rides the notifier's existing 30-second tick and its outbox
rather than adding a second scheduler, and runs before delivery so a
level that comes due on a tick is paged on that tick. Each target gets
its own outbox row and therefore its own Acknowledge token: the button in
a notification must acknowledge as the person holding the phone, not as
whoever was paged first.

Acknowledging or resolving takes the incident off the ladder. Snoozing
pauses it -- a deliberate "not now" holds the ladder where it is and it
resumes when the snooze runs out, rather than carrying on without the
person who asked for quiet.

Reminders and escalation never both run. A team with a ladder gets
escalation; a team without keeps today's behaviour exactly. Both would
mean two pages for one silence, which is how a tool gets muted.

A level whose targets cannot be reached -- no topic, a disabled account,
an empty rota -- is entered anyway, recorded as "nobody reachable", and
the ladder moves on. Stalling on a rung that cannot ring would be the
failure this feature exists to prevent, wearing the feature's clothes. A
policy with such a level cannot be created, but an older row could hold
one.

The API replaces the ladder wholesale rather than patching a rung,
because the levels are an order: editing one has to answer what happens
to the numbering of the others, and a whole-ladder PUT makes that the
client's decision and the edit atomic.

Verified against a live server as well as in tests: alice paged, nobody
answers, bob paged, nobody answers, the fallback topic paged once and the
timeline reading "level 2: bob" then "escalation exhausted: paged
terdut-oncall-all" -- and a second incident acknowledged before its
timeout, which woke nobody else.

No UI yet. The team-settings screens for escalation, integrations and
dead man's switches are all still missing, and they are one piece of work
rather than three.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:37:54 +02:00
niklas 94d23a593c Merge pull request 'Add an admin page, and move the behaviour settings into the database' (#15) from admin-settings into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #15
2026-09-20 16:26:27 +00:00
Niklas Ye b0a02c010b Add an admin page, and move the behaviour settings into the database
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m1s
Closes #5. Three of the server's tunables were environment variables,
which meant changing how long an incident waits before being paged again
required editing a chart, merging it and waiting for a reconcile. They
are behaviour rather than infrastructure, and the difference is who needs
to change them and how often.

The split is by who owns the value. What stays in the environment is
where the server is plugged in: the listen address, the DSN, the ntfy URL
and token, the public URL. Those are needed before the database is open
and two of them are credentials -- the settings endpoint reports that
ntfy is configured and that a token is set, and never what either is.

What moves is how it behaves: the notify repeat interval, the stale
window and the archive window. The environment variable becomes the seed
rather than the setting, written once on first start and never
overwritten, so a redeploy cannot put a chart's default back over an
administrator's edit -- the rule the per-team dead man's switches already
follow. The loops read the current value per tick, so a change at 02:00
is obeyed at 02:00.

Key/value rather than a column per knob: #6 and #7 will both add
settings, and a table shaped one-column-per-setting needs a migration for
each. The cost is that values are text and the accessor has to say what
type it wanted, which settings.go does in one place. Unknown keys are
refused rather than stored -- a typo that wrote notify_repeat_second
would otherwise sit in the table looking like configuration and doing
nothing -- and each value has bounds loose enough to catch a slipped
decimal point without having an opinion about anybody's rota.

Disabling an account is new, and is not deleting one. Deleting a user
nulls acknowledged_by and assigned_to, which quietly rewrites who did
what during an incident months after the fact. A disabled user cannot
authenticate by either credential, loses their sessions immediately, and
stays the name on every acknowledgement they made. The check is part of
the lookup in serveAs rather than a test afterwards, so there is no path
where the row is loaded and the flag is then forgotten.

The page itself is a fourth tab, shown only to an administrator and only
as a courtesy: every endpoint under it is refused with 403 regardless, so
somebody who types /admin gets an explanation rather than a blank screen.
It lists teams with their size and open-incident count, users with their
flags, and the settings with their bounds -- plus the environment half,
read-only, so somebody hunting for the ntfy URL learns where it lives
instead of concluding the server has none.

Delete is disabled rather than offered-and-refused for a team with open
incidents, and neither admin action is offered on your own account, since
the server refuses both.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:23:46 +02:00
niklas 303e7a3365 Merge pull request 'Remove the unauthenticated webhook and the SQLite migration script' (#14) from cleanup-after-teams into main
CI / chart (push) Successful in 1s
CI / test (push) Successful in 7s
CI / security (push) Successful in 16s
Reviewed-on: #14
2026-09-20 16:14:15 +00:00
Niklas Ye 7c87ae2af8 Remove the unauthenticated webhook and the SQLite migration script
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m54s
Both existed to carry an upgrade across, and both upgrades are done.

/api/alertmanager/webhook took no credential at all: anything able to
reach the port could open an incident for anybody. v0.12.0 kept it,
deprecated, so the teams release did not stop delivery while the
Alertmanager config was edited, and logged a line per payload asking to
be moved. The cluster's Alertmanager now posts on an integration key --
verified in the log, every two minutes, with no deprecation line since
the rollout -- so the door can be shut rather than left ajar until
somebody remembers. A sender still posting there gets the JSON 404 every
unknown /api path gets.

The tests move with it, which they should have done anyway: the harness
mints an integration key for the default team and posts on that, so they
exercise the path production uses rather than one only they still used.

scripts/sqlite-to-postgres.go goes the same way. It was written to be
temporary, it was the last thing needing modernc.org/sqlite, and this
install migrated on 2026-09-20. `go mod tidy` drops the driver and its
six transitive dependencies with it; the module graph is now chi, pgx,
pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out
of the v0.12.0 tag, which the README now says.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:11:30 +02:00
Niklas Ye 4c85e7646c Set the chart's placeholder version to 0.12.0
CI / test (push) Successful in 5s
CI / chart (push) Successful in 2s
CI / security (push) Successful in 15s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 20s
Release / image (push) Successful in 54s
Release / scan-image (push) Successful in 2s
Cosmetic, as in 041e159 and 989425e. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.12.0 that still says 0.11.1
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-20 15:36:46 +02:00
niklas 05f82220a6 Merge pull request 'Per-team dead man's switches, and the UI's team badge, filter and cards' (#13) from teams into main
CI / test (push) Successful in 5s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
2026-09-20 13:29:09 +00:00
niklas 5227eb0d5f Merge pull request 'Give each team its own dead man's switches, and the UI a team to show' (#12) from deadman-per-team into teams
CI / test (pull_request) Successful in 5s
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 16s
2026-09-20 13:28:30 +00:00
Niklas Ye 74359c72ab Give each team its own dead man's switches, and the UI a team to show
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 1m57s
The rest of #4. Two halves that belong together because they are the
same sentence from opposite ends: a team decides which of its alerts are
heartbeats, and the UI has to be able to say which team it is talking
about.

Switches were three environment variables, which made them one setting
for the whole install. That was the last piece of the alerting path a
team could not control: it could take its own alerts on its own key and
still not say which of them were heartbeats, or how long a silence had
to last. They are a row per team now, edited by an owner through
PUT /api/teams/{teamID}/deadman, and the sweeper runs each team against
its own matchers, timeout and severity.

The environment variables become the starting point rather than the
setting. Every team without a configuration is seeded from them at
startup, so an upgrade keeps watching exactly what it was watching, and
SeedDeadmanConfigs never overwrites -- a redeploy must not put the
environment's value back over an owner's edit. A team created later
watches nothing until somebody says otherwise: inheriting an
install-wide heartbeat would page a new team about a source it has never
heard of, and a switch nobody chose is the kind that gets muted rather
than fixed.

A matcher string with no alertname in it is refused at the door instead
of stored. Storing it would produce a switch that watches nothing
silently, which is the exact failure the feature exists to prevent.

NewRouter and Sweep lose their DeadmanConfig parameter -- there is no
longer one answer to hand them. The type stays, because parsing a
matcher string is still parsing a matcher string.

The UI side: rows in the queue carry a team badge, the filter row gains
a team chip per team, and "on call now" shows one card per team. All
three appear only when the viewer is in more than one team -- otherwise
they are the same word repeated down a list, which is noise rather than
information, and the single-team install reads exactly as it did before
teams existed.

Verified against a live two-team server as well as in tests: the
combined queue labelled by team, the team_id filter, a heartbeat that is
a heartbeat in one team and an ordinary alert in another, and a new
team's switches starting empty while the upgraded team keeps the
environment's.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 15:18:22 +02:00
niklas 43f69272f0 Merge pull request 'Add a system administrator role, and gate account management behind it' (#10) from admin-role into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #10
2026-09-20 13:10:47 +00:00
niklas 2de5c8412d Merge pull request 'Scope everything to a team, and route alerts by integration key' (#11) from teams into admin-role
CI / test (pull_request) Successful in 4s
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 11s
Reviewed-on: #11
2026-09-20 11:39:33 +00:00
Niklas Ye a4fbd60441 Scope everything to a team, and route alerts by integration key
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m49s
The core of #4, and what #1 is for: terdut stops being one shared space.
A team owns its incidents, alerts, schedule and integrations; a user sees
exactly the teams they are in. Everything that existed moves into one
Default team and every existing user becomes an owner of it, so the
upgrade is a no-op for the people using it.

Ingestion is the load-bearing half. An alert arrives on a team's
integration key, and the key is both the credential and the routing: it
says that the sender may post, and which team the alerts belong to. That
also closes the unauthenticated webhook -- the old path stays for one
release, deprecated and routed to the oldest team, so an upgrade does not
stop delivering while somebody edits the Alertmanager config.

Scoping is enforced in as few places as possible, because the failure
mode is silent. serveAs loads the caller's memberships once; list queries
carry `team_id = ANY(...)`; and every incident route goes through
incidentIDParam, which now parses the id AND checks the team in the same
call, so a new handler cannot remember the first half and forget the
second. Anything in another team is 404, never 403: whether an incident
exists is that team's business.

Two bugs this found, both of which would have been silent:

  * upsertAlerts decided "is this a new occurrence" by looking up the
    fingerprint alone. Across teams that made team B's first alert look
    like a re-send of team A's, so it opened no incident at all. The
    lookups are keyed on (team_id, fingerprint) now, as the index is.

  * Every uniqueness rule was written for one tenant. Two teams watching
    two clusters legitimately see the same fingerprint, the same
    groupKey, and want somebody on call on the same day; all three
    constraints move to include team_id.

Roles inside a team are separate from the system administrator flag: an
owner configures the team, a member works its incidents, and an admin is
NOT implicitly in every team -- administration is about accounts, not
about reading other people's incidents. An admin can still repair a team
whose owner has left, which is why requireTeamOwner lets them through.

A shift can only be given to somebody in the team. Paging a person who
cannot open the incident is worse than paging nobody.

The UI is updated only as far as keeping it working: it loads the
viewer's teams with the session and uses the first one, since nobody has
a second yet. "On call now" shows every team the viewer is in, named only
when there is more than one, so the common case reads exactly as before.
The team switcher, badges and per-team settings pages are the next step.

Breaking for API clients: the schedule endpoints moved under the team,
and /api/schedule/current returns an array rather than an object or a
404. terdut-tui will need a version for that.

Per-team dead-man configuration is deliberately not here. A heartbeat's
incident already opens in the team whose key received it, which is the
part that matters for isolation; moving the matchers out of env into
per-team rows is a change to how deadman.go is configured rather than to
who sees what.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 13:36:24 +02:00
Niklas Ye 1377d9005b Add a system administrator role, and gate account management behind it
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m37s
Until now every authenticated caller could create and delete users, set
anybody's password and mint anybody's API keys -- auth.go said so in a
comment. Defensible with one operator and a hand-made account; not once
people sign themselves up (#7), and not in a multi-tenant install (#4),
where the user list is no longer everybody who works here.

users.is_admin is the flag. AdminOnly gates creating and deleting users
and granting the flag itself. The endpoints that are self-service for
your own account and administration for somebody else's -- password,
ntfy topic, API keys -- go through requireSelfOrAdmin instead, because
which rule applies depends on the {id} in the path rather than on the
route.

Minting your own API key stays self-service. A key carries exactly the
rights of the user it belongs to, so issuing one is no more than signing
in again; requiring an admin for it would mean a responder cannot set up
the TUI without somebody else in the room.

/api/users stays readable by everybody. The queue's assignment control
and the on-call schedule both have to name people, and hiding the roster
from the people on it buys nothing.

THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already
hold these powers, so nobody's access changes on upgrade: it names what
is already true and leaves demotion as a deliberate act. Promoting only
user 1 would silently strip the others, and could leave an install whose
only administrator is an account nobody has a password for.

Two guards keep an install administrable: the last administrator can be
neither deleted nor demoted, and nobody can delete or demote themselves
-- the likelier accident, where the only admin clears their own flag
while tidying up and locks the door behind them.

No UI changes: there are no account-management screens yet. models.User
carries is_admin (not omitempty, so a client can tell false from an old
server), which is what #5's admin page will render from.
2026-09-20 13:20:04 +02:00
Niklas Ye e3ad19c110 Set the chart's placeholder version to 0.11.1
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 1m1s
Release / image (push) Successful in 1m25s
Release / scan-image (push) Successful in 24s
Cosmetic, as in 041e159 and 989425e. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

0.11.0 never reached the registry -- its release run failed at the test
gate -- so the placeholder moves on to the version that will.
2026-09-20 12:57:20 +02:00
Niklas Ye a8ee742533 Give release.yaml's test job the database ci.yaml already has
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
v0.11.0 was tagged and published nothing: release.yaml runs the same
`make fmt lint test` as ci.yaml, and the Postgres service the suite now
needs was added to ci.yaml alone. Its test job failed on the missing
TERDUT_TEST_DSN, and binaries, image, chart and scan-image are all
downstream of it, so they skipped. The tag stays -- a published tag is
immutable and moving one is how this repo ran v0.4.0 for ten days while
every artifact said v0.3.0 -- so the fix is the next version.

The two workflows carry their own copies of this block because a service
container cannot be factored into the Makefile the way the checks are.
That is the second copy, and the reason this failed: the gate is one
target, but what the gate needs to run is declared per workflow.
2026-09-20 11:12:41 +02:00
Niklas Ye 041e159e2a Set the chart's placeholder version to 0.11.0
CI / test (push) Successful in 5s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 12s
Release / test (push) Failing after 4s
Release / binaries (push) Has been skipped
Release / image (push) Has been skipped
Release / chart (push) Has been skipped
Release / scan-image (push) Has been skipped
Cosmetic, as in 989425e and e78f494. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.11.0 that still says 0.10.2
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-20 11:10:21 +02:00
niklas cede8743a8 Merge pull request 'Take the database password from PGPASSWORD, not the DSN' (#9) from postgres-dsn-password into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 10s
Reviewed-on: #9
2026-09-20 09:07:32 +00:00
Niklas Ye cc31c993dd Take the database password from PGPASSWORD, not the DSN
CI / test (pull_request) Successful in 4s
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 11s
The chart asked for a whole DSN in a Secret. Nothing writes one: the
Zalando postgres operator generates a Secret with `username` and
`password` keys and no connection string, so wiring the wrapper chart up
would have meant hand-maintaining a second copy of a password the
operator owns and rotates on a from-scratch rebuild -- which is charts#176
again, the issue miniflux closed by doing the opposite.

So the DSN becomes a plain value with no password in it, and the password
arrives as PGPASSWORD from a Secret. pgx fills in from libpq's PG*
environment variables whatever the DSN omits, exactly as miniflux's
lib/pq does. Verified rather than assumed, against a real server: a
password-less DSN connects with PGPASSWORD set, and fails with
`password authentication failed` when it is wrong, so the variable is
doing the work rather than being quietly ignored.

It also keeps the credential out of the rendered manifest and out of
`kubectl describe pod`, which a DSN-with-password does not.
2026-09-20 11:00:05 +02:00
niklas 21f0eec807 Merge pull request 'Move the database to Postgres' (#8) from postgres into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
Reviewed-on: #8
2026-09-20 08:54:24 +00:00
Niklas Ye dc39e3a5d3 Move the database to Postgres, before teams need the schema
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 2m5s
First step of #1, and it goes first for one reason: #4 adds a team_id to
nearly every table, and doing that twice -- once for SQLite, once for
Postgres -- is work nobody gets paid for. The teams migrations now only
have to be written against one database.

The ten SQLite migrations are replaced by a single Postgres baseline
rather than ported one by one. They were incremental in a way that has
no value on a fresh install: 004 adds columns 008 drops again, and 008's
backfill rewrites data a Postgres database never had. The history stays
in git; the schema they add up to is now 001_baseline.sql.

Timestamps stay BIGINT unix seconds and are NOT converted to timestamptz.
Everything in Go already speaks epochs, so converting would have been a
second, larger change riding along inside this one. It is worth doing on
its own. The JSON columns did move to jsonb, because #4 will want to
filter and index on labels.

Most of the port is mechanical -- 170 placeholders from ? to $1 -- but
four things needed more than a search and replace:

  * Dynamically built WHERE clauses cannot keep their numbering straight
    by hand, so they hand out placeholders through sqlArgs instead. A
    filter can now be added or reordered without renumbering anything.

  * SUM(resolved_at IS NULL) was SQLite counting a boolean as 0 or 1.
    Postgres has no sum(boolean), and this was breaking every dead man's
    switch -- silently, since the sweeper only logs. Now COUNT(*) FILTER.

  * unixepoch() became FLOOR(EXTRACT(EPOCH FROM now()))::bigint. The
    FLOOR is load-bearing: a bare cast rounds half up, so a row written
    at .6 of a second claimed a timestamp a second in the future and
    disagreed with the time.Now().Unix() the Go side stamps.

  * The unique-violation check matched SQLite's error text. It matches
    SQLSTATE 23505 now, so a renamed constraint cannot turn a 409 back
    into a 500.

Tests need a real Postgres, because there is no in-memory Postgres the
way there was an in-memory SQLite. Each test gets its own schema on a
shared server -- cheaper than a database each, and still isolated.
TERDUT_TEST_DSN says where it is; `make test-db` starts one locally and
ci.yaml runs one as a service container. An unset DSN fails the suite
rather than skipping it: a run that quietly tests nothing is worse than
one that does not run.

TestMigration_BackfillCarriesAckAndComments is deleted along with the
migrations it replayed. What it protected -- an upgrade not losing
acknowledgements and comments -- now belongs to scripts/sqlite-to-postgres.go,
which is build-tagged so the SQLite driver stays out of the server
binary. Both are meant to be deleted once this install has migrated.

The chart loses the PVC, the data volume and the python backup sidecar,
and requires database.dsnSecret.name: it provisions no database and
cannot guess where the credentials live, so a render without it is meant
to fail. Backups move to where Postgres actually runs. The other half of
that -- the postgresql CR, the k8up pg_dump annotation and the network
policy -- is a change to the wrapper chart in Ryuvia/charts and is not in
here.

Verified rather than assumed: the gate is green with -race against
Postgres 17, govulncheck and gitleaks are clean, and the migration script
was run end to end against a SQLite database built at the old schema and
seeded in every table. Ids survive, so incidents keep their numbers and
every foreign key still points where it did; the identity sequences are
moved past the copied ids, and a webhook after the migration opened
incident 12 rather than colliding at 1.
2026-09-20 10:44:12 +02:00
88 changed files with 10724 additions and 1145 deletions
+23
View File
@@ -45,6 +45,29 @@ jobs:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
# The suite needs a real Postgres -- there is no in-memory Postgres the way there was
# an in-memory SQLite, so each test gets its own schema on a shared server instead.
# The job and the service share the dind bridge, so the service is reachable by its
# name rather than on localhost.
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: terdut
POSTGRES_PASSWORD: terdut
POSTGRES_DB: terdut_test
options: >-
--health-cmd "pg_isready -U terdut -d terdut_test"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
# `make test` fails without this rather than skipping, so a green job here means
# the tests actually ran against a database.
TERDUT_TEST_DSN: postgres://terdut:terdut@postgres:5432/terdut_test?sslmode=disable
steps:
- name: Checkout
env:
+21
View File
@@ -35,6 +35,27 @@ jobs:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
# The same database ci.yaml's test job gets, for the same reason: `make test` needs a
# real Postgres and fails without TERDUT_TEST_DSN rather than skipping. This job is
# the gate every publishing job below hangs off, so it has to be able to run the
# suite -- v0.11.0 was tagged with the service here missing and published nothing.
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: terdut
POSTGRES_PASSWORD: terdut
POSTGRES_DB: terdut_test
options: >-
--health-cmd "pg_isready -U terdut -d terdut_test"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
TERDUT_TEST_DSN: postgres://terdut:terdut@postgres:5432/terdut_test?sslmode=disable
steps:
- name: Checkout
env:
+7 -1
View File
@@ -22,10 +22,16 @@ Two things about this repo specifically:
`Ryuvia/charts` to this version. Added 2026-09-02; every release up to and including
v0.9.3 was published with no CVE check at all.
- **The wrapper chart has two `tag:` lines** — the app image and the python backup sidecar —
so `chart-bump` needs `--image "$IMAGE"` to know which one moves.
so `chart-bump` needs `--image "$IMAGE"` to know which one moves. That sidecar backs up
SQLite; the Postgres move (#2) retires it in favour of a `postgresql` CR with a k8up
`pg_dump` annotation, after which only the app image's tag is left.
## Checks
The tests need a Postgres: `make test-db` starts one and prints the DSN, `make test-db-stop`
removes it, and `TERDUT_TEST_DSN` is how both the Makefile and `ci.yaml`'s service container
point the suite at it. Without it the suite fails rather than skipping, on purpose.
`make fmt lint test helm-lint` **is** what the pipeline runs — `ci.yaml` and `release.yaml`
call these targets rather than restating them, the way riksdata and rd-web do. A green gate
here and a green pipeline are the same code, not two descriptions of it. `test` adds `-race`,
+45 -5
View File
@@ -25,12 +25,45 @@ help: ## Show this help
#
# These three mirror .gitea/workflows/ci.yaml step for step, so a green `make fmt
# lint test` here means the same thing CI means. The one deliberate difference is
# -race below.
# -race below. Both need a Postgres to test against; see test-db.
# The suite needs a Postgres, because the server does: there is no in-memory
# Postgres the way there was an in-memory SQLite. TERDUT_TEST_DSN says where, and
# the tests fail rather than skip without it — a suite that quietly tests nothing
# is worse than one that does not run. `make test-db` starts a local one;
# ci.yaml runs the same thing as a service container.
TEST_DB_CONTAINER ?= terdut-test-db
TEST_DB_PORT ?= 5433
TEST_DB_IMAGE ?= docker.io/library/postgres:17-alpine
export TERDUT_TEST_DSN ?= postgres://terdut:terdut@localhost:$(TEST_DB_PORT)/terdut_test?sslmode=disable
.PHONY: test
test: ## Run the test suite
test: ## Run the test suite (needs TERDUT_TEST_DSN; see test-db)
go test -race ./...
# podman, with docker as the fallback: this is a dev convenience, not part of the
# pipeline, where the database arrives as a service container instead.
.PHONY: test-db
test-db: ## Start a local Postgres for the tests
@runtime=$$(command -v podman || command -v docker); \
if [ -z "$$runtime" ]; then echo "need podman or docker"; exit 1; fi; \
$$runtime run -d --rm --name $(TEST_DB_CONTAINER) \
-e POSTGRES_USER=terdut -e POSTGRES_PASSWORD=terdut -e POSTGRES_DB=terdut_test \
-p $(TEST_DB_PORT):5432 $(TEST_DB_IMAGE) >/dev/null; \
printf 'waiting for postgres'; \
for i in $$(seq 1 60); do \
if $$runtime exec $(TEST_DB_CONTAINER) pg_isready -U terdut -d terdut_test >/dev/null 2>&1; then \
echo " ready: $(TERDUT_TEST_DSN)"; exit 0; \
fi; \
printf '.'; sleep 1; \
done; \
echo " timed out"; exit 1
.PHONY: test-db-stop
test-db-stop: ## Stop the local test Postgres
@runtime=$$(command -v podman || command -v docker); \
$$runtime rm -f $(TEST_DB_CONTAINER) >/dev/null 2>&1 || true
# CI runs a bare `go test ./...`. This is stricter on purpose: the sweeper, the
# notifier goroutine and the deadman sweep all touch the same single-connection
# database, and a race there would surface as a flaky production incident rather
@@ -54,16 +87,23 @@ fmt: ## Report unformatted files
echo "gofmt needed:"; echo "$$unformatted"; gofmt -d .; exit 1; \
fi
# database.dsn has no default and the deployment `required`s it: the chart
# provisions no database and cannot guess where it is, so a render without it is
# meant to fail. Setting it here keeps the lint honest about what a working
# install needs.
HELM_LINT_SET = --set image.tag=v0.0.0 \
--set 'database.dsn=postgres://terdut@terdut-postgres:5432/terdut?sslmode=require'
.PHONY: helm-lint
helm-lint: ## Lint and render the chart
helm lint $(HELM_CHART) --set image.tag=v0.0.0
helm lint $(HELM_CHART) $(HELM_LINT_SET)
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
--set image.tag=v0.0.0 >/dev/null
$(HELM_LINT_SET) >/dev/null
@# networking.listener defaults to "", which attaches the route to every
@# matching listener including plaintext HTTP. Production sets it, so the
@# default render proves nothing about the path that actually ships.
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
--set image.tag=v0.0.0 --set networking.listener=https-terdut >/dev/null
$(HELM_LINT_SET) --set networking.listener=https-terdut >/dev/null
## --- release ---
+422 -51
View File
@@ -9,7 +9,7 @@ Incident management server for teams using Prometheus Alertmanager.
- Alert and incident statistics, including MTTA and MTTR
- Web UI for phones and desktops, served by the same binary
- REST API with per-user API key authentication
- Single binary, SQLite storage — trivial to self-host
- Single binary plus a Postgres — straightforward to self-host
---
@@ -47,12 +47,13 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
The server serves a web UI at `/`: the incident queue, each incident's alerts
and timeline with every action (acknowledge, assign, snooze, note, resolve,
archive), who is on call, the alert feed, and changing your own password. It is
built for a phone first. On a phone it has a bottom tab bar and a sticky action
bar, it follows the system's dark mode, and it can be added to the home screen.
From 900px wide it switches to a sidebar with the queue and the incident side by
side. Schedule editing, statistics and user management remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
archive), who is on call, the alert feed, and an *Account* tab for your own
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
it navigates through a hamburger menu and has a sticky action bar, it follows the
system's dark mode, and it can be added to the home screen. From 900px wide it switches
to a sidebar with the queue and the incident side by side. The Stats page shows
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
chosen range.
You sign in with a username and password. Users have no password until one is
set, and a user without one can only use API keys:
@@ -85,15 +86,64 @@ How a browser stays signed in:
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
the web UI (`/incidents/{id}`).
A **Team** tab holds everything a team owns, 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
```bash
docker build -t terdut-server .
docker run -p 8080:8080 -v $(pwd)/data:/data \
-e TERDUT_DB_PATH=/data/terdut.db \
docker run -p 8080:8080 \
-e TERDUT_DB_DSN='postgres://terdut:secret@host.docker.internal:5432/terdut?sslmode=disable' \
terdut-server
```
The server creates its own schema on startup and needs a reachable Postgres; it stores nothing on
disk, so there is no volume to mount.
### Kubernetes
A Helm chart is published from this repository as an OCI artifact, versioned in lockstep
@@ -116,43 +166,69 @@ than an `Ingress`. TLS is terminated at the gateway, so the server itself never
| `networking.listener` | `""` | Gateway listener (`sectionName`) to bind to. Empty attaches to every matching listener, **including plaintext HTTP** — set it to the HTTPS listener's name to serve TLS only |
| `networking.servicePort` | `8080` | Port the route forwards to; keep in sync with `service.port` |
| `bootstrap.enabled` | `true` | Runs a post-install hook that creates the first user and stores its API key in the `<release>-admin-key` Secret. Already-bootstrapped servers are left alone |
| `backupSidecar.enabled` | `true` | Adds an idle `python` sidecar and the [k8up](https://k8up.io/) annotations that dump the database through it |
| `database.dsn` | `""` | **Required.** Postgres DSN, with no password in it. The chart provisions no database |
| `database.passwordSecret.name` | `""` | Secret supplying `PGPASSWORD`. With the Zalando postgres operator, the Secret it generates for the role |
| `database.passwordSecret.key` | `password` | Key within that Secret |
The API key travels in an `Authorization: Bearer` header, so set `networking.listener` whenever the
hostname is reachable outside a trusted network.
#### The database
The chart provisions no database: it takes a DSN and expects a Postgres that already exists. In this
cluster the wrapper chart declares an `acid.zalan.do/v1 postgresql` CR; anywhere else, any reachable
Postgres 14+ will do.
The DSN carries no password. pgx falls back to libpq's environment variables for whatever the DSN
leaves out, so the password arrives as `PGPASSWORD` from a Secret and never appears in values, in
the rendered manifest or in `kubectl describe pod`. With the postgres operator that Secret is the
one it generates for the role, so a rebuild mints a new password with nothing to keep in sync —
the same wiring miniflux uses.
The server migrates its own schema on startup, so a new database only has to exist and be writable.
#### Backups
The server image is `FROM scratch` — the binary and nothing else — so there is no interpreter to
run a database dump in, and the database runs in WAL mode, where a file-level copy of the volume is
not crash-consistent. The chart therefore ships an idle `python:*-alpine` sidecar that shares the
data volume, and points k8up's `backupcommand` at it with `k8up.io/backupcommand-container`. Without
that annotation k8up execs into `.spec.containers[0]` and the dump fails.
Postgres is backed up where it runs, not from here. The database pod carries a
[k8up](https://k8up.io/) `k8up.io/backupcommand` annotation that streams a `pg_dump`, the same way
gitea and immich do in this cluster.
The dump is buffered and sanity-checked before its first byte reaches stdout, because k8up streams
stdout straight into Restic: a dump that dies partway is otherwise stored as a silently truncated
snapshot that k8up still reports as successful.
Set `backupSidecar.enabled=false` if you back the volume up some other way.
This used to be the app's problem: the SQLite database lived on a PVC beside the server, the image
is `FROM scratch` with no interpreter to dump it, and WAL mode makes a file-level copy of the volume
non-crash-consistent — so the chart shipped an idle `python:*-alpine` sidecar purely to give k8up
somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all gone.
---
## 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 |
|---|---|---|
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
| `TERDUT_DB_PATH` | `terdut.db` | Path to the SQLite database file |
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | 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_DEADMAN_MATCHERS` | `alertname=Watchdog` | Which alerts are [dead man's switches](#dead-mans-switch). `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
| `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` | **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_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_NTFY_URL` | — | ntfy server to publish push notifications to. Empty disables notifications entirely |
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
| `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_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.
@@ -166,19 +242,39 @@ In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and
## 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
receivers:
- name: terdut
webhook_configs:
- url: http://terdut-server:8080/api/alertmanager/webhook
- url: http://terdut-server:8080/api/integrations/<key>/alertmanager
send_resolved: true
route:
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.
If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give
@@ -280,10 +376,20 @@ exactly as it was rather than with a hole in it.
### Push notifications
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with
`PUT /api/users/{id}/notify`; a user with no topic falls back to
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
If neither yields a topic, nothing is queued.
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
under *Account* in the web UI, where a **Send a test push** button proves it
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
the API, and an administrator may set somebody else's. A user with no topic
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
nobody on call. If neither yields a topic, nothing is queued.
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
something a user picks. Only the topic is per-person.
A topic is a shared secret with the ntfy server: anyone who knows it can both
read the pages and publish to it, so an unguessable one is worth the trouble.
That is also why the topic never appears in an incident's timeline, which every
API key can read.
Three things get pushed:
@@ -328,6 +434,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
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
A resolved webhook is the only signal that an alert has stopped firing, so a
@@ -362,11 +512,31 @@ kube-prometheus-stack already ships the alert for this. `Watchdog` is
nothing unless something downstream notices it stop. That is what
`TERDUT_DEADMAN_MATCHERS` defaults to.
**Switches belong to a team**, which decides which of its own alerts are
heartbeats and how long a silence has to last. Each **switch** is a row of its
own — a name, one matcher, a timeout and a severity — so switches in one team
can have different deadlines. An owner adds and removes them on **Team →
Switches**, which lists each with a status (**healthy**, **dead**, or
**dormant** until its first heartbeat), when it was last heard from, and when it
last opened an incident; a matcher that several clusters satisfy is broken down
per cluster. The API is `POST`/`DELETE /api/teams/{teamID}/deadman/switches`. A
missed heartbeat opens an incident in the team whose integration received it.
Removing a switch stops the watching; an incident it already opened stays open
until somebody resolves it.
The environment variables are the starting point, not the setting: the **first**
time the server starts, every team is given a switch per default matcher from
them, once. After that a team's switches are its own — an owner's edit or
deletion is never put back by a redeploy. A team created later starts watching
nothing until its owner says otherwise — inheriting an install-wide heartbeat
would page a new team about a source it has never heard of.
A matcher is a set of exact label conditions, one of which must be the
`alertname`:
`alertname`, in the format the environment variable uses (one matcher per switch; the
variable takes several, separated by `;`):
```
TERDUT_DEADMAN_MATCHERS="alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat"
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
```
**The unit of monitoring is the fingerprint, not the alert name.** Two clusters
@@ -419,7 +589,7 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### 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:
```
@@ -429,6 +599,55 @@ Authorization: Bearer <api-key>
or the web UI's session cookie. A request that carries an `Authorization` header
is judged on that header alone.
Two kinds of user exist. An **administrator** manages accounts: creating and
deleting users, setting anybody's password, minting keys for anybody, and
granting the flag itself. Everybody else works incidents — acknowledging,
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.
**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
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
nobody can delete or demote themselves.
Endpoints that require the flag answer `403` with
`{"error":"administrator access required"}`.
**Teams** are the unit of tenancy, and are a separate axis from the administrator
flag. A team owns its incidents, alerts, schedule and integrations, and a user
sees exactly the teams they belong to. Within a team an **owner** configures it
(schedule, integrations, membership) and a **member** works its incidents.
An administrator crosses that line in one direction only. They **configure any
team** without being in it — every owner-only endpoint accepts the flag, because
otherwise a team whose last owner left could never be repaired. They do **not
read any team**: the queue, the alerts and the incidents are filtered by real
membership, so an administrator sees a team's work only by joining it, which is
a membership change and shows up as one. Administration is about accounts and
the shape of a team, not about reading other people's incidents.
Anything belonging to a team you are not in answers `404`, not `403`: whether an
incident exists is itself something only its team should learn.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
@@ -439,20 +658,77 @@ is judged on that header alone.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/bootstrap` | Create first user + API key `{"username","email","password"?}` (only works on empty DB) |
| `GET` | `/api/users` | List users |
| `POST` | `/api/users` | Create user `{"username","email"}` |
| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) |
| `PUT` | `/api/users/{id}/notify` | Set push notification target `{"ntfy_topic"}` — empty string clears it |
| `PUT` | `/api/users/{id}/password` | 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` | Issue API key `{"name"}` — key shown once |
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key |
**admin** marks an endpoint that requires the administrator flag; **self or
admin** marks one you may use on your own account and an administrator may use
on anybody's.
| 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 |
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
| `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}/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 |
| `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
Alerts arrive on a team's integration key. The key is both the credential and the
routing: it says that the sender may post, and which team the alerts belong to.
Create one with `POST /api/teams/{teamID}/integrations`, which returns the key
and the full URL once and stores only a SHA-256 hash.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/alertmanager/webhook` | Alertmanager v4 webhook receiver (no auth) |
| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key |
This is the only way in. The pre-teams `POST /api/alertmanager/webhook` took no
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
**owner** below means an owner of that team *or* a system administrator, who
passes every one of these without being a member — see
[Authentication](#authentication). **member** means membership and nothing else: an
administrator who is not in the team gets the same `404` as anybody else.
| Method | Path | Who | Description |
|---|---|---|---|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
| `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 |
| `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"}` |
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
| `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 |
| `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/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing |
| `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent |
| `DELETE` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Stop watching. An incident it opened stays open. `404` for a switch of another team |
### Notifications
@@ -639,13 +915,23 @@ unknown" rather than being rejected.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing |
| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none |
| `DELETE` | `/api/schedule/{id}` | Remove schedule entry |
Each team keeps its own rota, so two teams can have two different people on call
on the same day. The person taking a shift has to be in the team — paging
somebody who cannot open the incident is worse than paging nobody.
| Method | Path | Who | Description |
|---|---|---|---|
| `POST` | `/api/teams/{teamID}/schedule` | **owner** | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing |
| `GET` | `/api/teams/{teamID}/schedule` | member | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
| `DELETE` | `/api/teams/{teamID}/schedule/{id}` | **owner** | Remove schedule entry |
| `GET` | `/api/schedule/current` | any | Who is on call today (UTC) in **every** team the caller is in — one entry per team, `[]` when nobody anywhere |
### Statistics
Every figure counts the caller's own teams only: a report that counted other
teams' incidents would leak their volume, and their alert names through the
top-alerts list, and would not be a number about the reader's work anyway.
All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD`, and exclude archived rows to match the default list views. Alert stats filter on `received_at`; incident stats filter on `triggered_at`.
| Method | Path | Description |
@@ -662,6 +948,81 @@ averages over incidents that have actually been acknowledged or resolved, and ar
---
## Upgrading to teams
Everything that existed before teams moves into one team called **Default**, and
every existing user becomes an owner of it. The upgrade is a no-op for the
people using it: the same queue, the same schedule, the same incidents, with a
name on them.
What changes, and will need attention:
- **Alert ingestion moved.** Mint a key with
`POST /api/teams/{teamID}/integrations` and point Alertmanager at the URL it
returns. In v0.12.0 the old `POST /api/alertmanager/webhook` still worked,
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
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
on call — instead of a single object or a 404. This is a breaking API change
for anything that reads it, terdut-tui included.
- **Uniqueness is per team now.** Two teams can legitimately see the same alert
fingerprint, the same Alertmanager groupKey, and put somebody on call on the
same date.
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
`_SEVERITY` are no longer the setting; they are the default each existing team
is seeded with at startup, after which an owner manages them per team through
`/api/teams/{teamID}/deadman/switches` and a redeploy never overwrites that.
Nothing else about an incident changes, and incidents never move between teams:
an alert belongs to whichever team's key it arrived on.
## Upgrading to roles
Before this release every authenticated caller could create and delete users,
set anybody's password and mint anybody's API keys. That is now the
administrator flag, and the migration **makes every existing user an
administrator** — they already held those powers, so nobody's access changes on
upgrade and demotion is a deliberate act afterwards. Promoting only the first
user would have silently stripped the rest, and could leave an install whose
only administrator is an account nobody has a password for.
Users created after the upgrade are not administrators. Hand the flag out with:
```bash
curl -X PUT https://terdut.example.com/api/users/7/admin \
-H "Authorization: Bearer $TERDUT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"is_admin": true}'
```
Nothing in the API changed shape, so terdut-tui needs no new version — but a
non-administrator now gets `403` where a `200` used to come back.
## Upgrading from SQLite
Versions up to v0.10.2 stored everything in a SQLite file. From v0.11.1 the server needs
`TERDUT_DB_DSN` and keeps nothing on disk.
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
git show v0.12.0:scripts/sqlite-to-postgres.go > sqlite-to-postgres.go
```
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
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.
## Upgrading to incidents
The incidents release moves the workflow off alerts, which is a **breaking API
@@ -712,15 +1073,23 @@ There is no migration and no schema change. An existing open incident from a
## Development
```bash
go test ./... # run all tests
make test-db # start a local Postgres for the tests (podman or docker)
make test # run all tests
go build ./... # compile all packages
go run ./cmd/terdut # run locally
go run ./cmd/terdut # run locally (needs TERDUT_DB_DSN)
```
The tests need a real Postgres, because the server does — there is no in-memory Postgres the
way there was an in-memory SQLite. `TERDUT_TEST_DSN` says where it is, `make test-db` starts
one on port 5433 and prints the DSN, and `make test-db-stop` removes it. Each test gets its
own schema on that server, so tests cannot see each other's rows. An unset `TERDUT_TEST_DSN`
fails the suite rather than skipping it: a run that quietly tests nothing is worse than one
that does not run.
`make fmt lint test helm-lint` is the gate. It mirrors `.gitea/workflows/ci.yaml` step for
step, so a green run here means a green pipeline — with one deliberate exception: `make test`
adds `-race`, which CI does not. The sweeper, the notifier goroutine and the dead man's switch
sweep all touch the same single database connection, and a race between them would surface as
sweep all run concurrently against the same database, and a race between them would surface as
a flaky incident in production rather than as a red build.
The web UI lives in `internal/web/static/` as plain HTML, CSS and ES modules,
@@ -776,7 +1145,9 @@ Two things the release process needs to know about this repo:
do not bump the wrapper chart to that version — it cannot unpublish anything. The image is
`FROM scratch`, so trivy sees exactly one target, the Go binary and its module graph.
- **The wrapper chart's `values.yaml` has two `tag:` lines** — the app image and the python
backup sidecar — so `chart-bump` is given `--image` to say which one moves.
backup sidecar — so `chart-bump` is given `--image` to say which one moves. The sidecar is
on its way out with SQLite: once the wrapper chart drops it and declares a `postgresql` CR
instead, there is one `tag:` line again, and `--image` becomes belt and braces.
The wrapper chart must have **its own `version:` bumped in the same commit**. Flux reconciles
with `reconcileStrategy: ChartVersion`, so a chart whose version did not change produces no
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.10.2
appVersion: "v0.10.2"
version: 0.24.0
appVersion: "v0.24.0"
+16 -65
View File
@@ -10,50 +10,15 @@ spec:
selector:
matchLabels:
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
# The data PVC is ReadWriteOnce, so a RollingUpdate deadlocks: the new pod
# cannot attach the volume until the old one releases it, and the old one is
# not torn down until the new one is ready.
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
# sweeper and the notifier are unsynchronised singletons, and two replicas
# overlapping during a rollout would both page for the same incident.
strategy:
type: Recreate
template:
metadata:
labels:
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
{{- if .Values.backupSidecar.enabled }}
annotations:
# Dumps the whole database: incidents, alerts, users, API key hashes,
# the schedule and the notification outbox.
#
# Runs in the `backup` sidecar, NOT in the app container: the server
# image is FROM scratch and has no interpreter at all. k8up execs into
# .spec.containers[0] unless told otherwise, hence the explicit
# k8up.io/backupcommand-container.
#
# Buffered and sanity-checked before the first byte reaches stdout: k8up
# streams stdout straight into restic, so a dump that dies partway is
# stored as a silently-truncated snapshot that k8up still reports as
# Succeeded. The check counts users rather than incidents -- incidents
# are swept and archived, so an empty incidents table is a legitimate
# state, whereas a database with no users never is.
#
# The connection is read-only but the mount is not: the database runs in
# WAL mode, and opening it mode=ro still needs write access to the -shm
# wal-index.
#
# chr(10), not '\n': k8up parses this annotation with go-shellquote.
k8up.io/backupcommand-container: backup
k8up.io/backupcommand: >-
python3 -c "import sqlite3, sys;
con = sqlite3.connect('file:/data/terdut.db?mode=ro', uri=True);
con.execute('BEGIN');
users = con.execute('SELECT count(*) FROM users').fetchone()[0];
out = chr(10).join(con.iterdump()) + chr(10);
(users > 0 and out.rstrip().endswith('COMMIT;'))
or sys.exit('terdut: db dump failed sanity checks');
sys.stdout.write(out)"
k8up.io/file-extension: ".sql"
k8up.io/backup: "true"
{{- end }}
spec:
enableServiceLinks: false
containers:
@@ -67,8 +32,18 @@ spec:
env:
- name: TERDUT_ADDR
value: ":{{ .Values.service.port }}"
- name: TERDUT_DB_PATH
value: "/data/terdut.db"
- name: TERDUT_DB_DSN
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
{{- if .Values.database.passwordSecret.name }}
# The password reaches pgx through libpq's environment variable
# rather than through the DSN, so it stays out of the rendered
# manifest. pgx fills in from PG* whatever the DSN leaves out.
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.passwordSecret.name }}
key: {{ .Values.database.passwordSecret.key }}
{{- end }}
- name: TERDUT_STALE_AFTER
value: "{{ .Values.sweeper.staleAfter }}"
- name: TERDUT_ARCHIVE_AFTER
@@ -96,9 +71,6 @@ spec:
key: {{ .Values.notify.tokenSecret.key }}
{{- end }}
{{- end }}
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /healthz
@@ -110,25 +82,4 @@ spec:
port: http
initialDelaySeconds: 5
{{- if .Values.backupSidecar.enabled }}
# Idle sidecar. It exists only so k8up has a container with a sqlite3
# module to exec the backupcommand in. Mounted read-write on purpose:
# see the note on the backupcommand annotation above.
- name: backup
image: "{{ .Values.backupSidecar.image.repository }}:{{ .Values.backupSidecar.image.tag }}"
imagePullPolicy: {{ .Values.backupSidecar.image.pullPolicy }}
command: ["sleep", "infinity"]
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "16Mi"
cpu: "10m"
limits:
memory: "64Mi"
{{- end }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-data
-13
View File
@@ -1,13 +0,0 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-data
namespace: {{ .Release.Namespace }}
spec:
storageClassName: {{ .Values.storage.storageClass | quote }}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.storage.size }}
+26 -14
View File
@@ -11,9 +11,28 @@ image:
tag: "latest"
pullPolicy: IfNotPresent
storage:
size: 1Gi
storageClass: synology-iscsi
# Postgres connection. The chart provisions no database; it expects one to exist.
database:
# Required. A DSN with no password in it:
# postgres://terdut@terdut-postgres:5432/terdut?sslmode=require
#
# The password is deliberately a separate setting. pgx falls back to libpq's
# environment variables for anything the DSN omits, so PGPASSWORD supplies it
# without the credential appearing in values, in the rendered manifest, or in
# `kubectl describe pod`.
dsn: ""
# Where PGPASSWORD comes from. With the Zalando postgres operator this is the
# Secret it generates for the role — `<user>.<cluster>.credentials.postgresql.acid.zalan.do`,
# whose keys are `username` and `password` — so a from-scratch rebuild mints a
# new password and the server picks it up with nothing to keep in sync.
#
# Read at process start only: rotating the password needs a pod restart.
#
# Leave name empty only if the DSN carries its own password, which puts it in
# the manifest.
passwordSecret:
name: ""
key: password
service:
type: ClusterIP
@@ -88,17 +107,10 @@ notify:
name: ""
key: token
# The server image is FROM scratch — just the binary, with no shell, no sqlite3
# and no python — so a k8up backupcommand cannot run in the app container. This
# idle sidecar shares the data volume and is selected with
# k8up.io/backupcommand-container. Only the stdlib sqlite3 module is used, so any
# python image works.
backupSidecar:
enabled: true
image:
repository: python
tag: "3.13-alpine"
pullPolicy: IfNotPresent
# Backups are no longer this chart's business. The SQLite database lived on a PVC
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
# pg_dump annotation on the database pod itself.
bootstrap:
enabled: true
+16 -3
View File
@@ -18,7 +18,7 @@ var version = "dev"
func main() {
cfg := config.Load()
database, err := db.Open(cfg.DBPath)
database, err := db.Open(cfg.DSN)
if err != nil {
log.Fatalf("open db: %v", err)
}
@@ -36,9 +36,22 @@ func main() {
RepeatEvery: cfg.NotifyRepeat,
}
// Dead man's switches live per team now. The environment variables are the
// defaults a team starts from: every team without a configuration of its
// own gets one from them here, and an owner's later edit is never
// overwritten by a redeploy.
deadman := api.ParseDeadmanConfig(cfg.DeadmanMatchers, cfg.DeadmanTimeout, cfg.DeadmanSeverity)
if err := api.SeedDeadmanConfigs(context.Background(), database, deadman); err != nil {
log.Fatalf("seed dead man's switch defaults: %v", err)
}
router := api.NewRouter(database, notify, deadman)
// 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{
Addr: cfg.Addr,
@@ -51,7 +64,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, deadman, notify)
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notify)
go api.StartNotifier(ctx, database, notify)
go func() {
+7 -10
View File
@@ -4,18 +4,15 @@ go 1.25.9
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // 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/sys v0.47.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+28 -51
View File
@@ -1,55 +1,32 @@
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/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.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/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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=
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/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+504
View File
@@ -0,0 +1,504 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"testing"
)
// The bootstrap user is an administrator; everybody it creates afterwards is
// not. These tests are about the line between them.
// id64 spells an id into a path segment.
func id64(n int64) string { return strconv.FormatInt(n, 10) }
// member creates an ordinary user and an API key for it, and returns a caller
// that authenticates as them. Minting the key goes through the admin's own
// credentials, which is how a real install hands one out.
func member(t *testing.T, s *ts, username string) (id int64, call func(method, path string, body any) *http.Response) {
t.Helper()
resp := s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": username, "email": username + "@test.com"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create %s: %d", username, resp.StatusCode)
}
var user struct {
ID int64 `json:"id"`
IsAdmin bool `json:"is_admin"`
}
decode(t, resp, &user)
if user.IsAdmin {
t.Fatalf("a created user must not be an administrator")
}
// Into the default team as a plain member: being in a team is what lets
// somebody work its incidents, and is separate from administering accounts.
resp = s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("add %s to the team: %d", username, resp.StatusCode)
}
resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("mint key for %s: %d", username, resp.StatusCode)
}
var key struct {
Key string `json:"key"`
}
decode(t, resp, &key)
return user.ID, func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
}
// The whole point of the release: a user who is not an administrator cannot
// manage other people's accounts. Every one of these was open to any
// authenticated caller before.
func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "member")
cases := []struct {
name string
method string
path string
body any
}{
{"create a user", http.MethodPost, "/api/users",
map[string]string{"username": "sneaky", "email": "sneaky@test.com"}},
{"delete the admin", http.MethodDelete, "/api/users/1", nil},
{"grant themselves admin", http.MethodPut, "/api/users/" + id64(memberID) + "/admin",
map[string]bool{"is_admin": true}},
{"set the admin's password", http.MethodPut, "/api/users/1/password",
map[string]string{"password": "hunter2-hunter2"}},
{"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys",
map[string]string{"name": "borrowed"}},
{"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify",
map[string]string{"ntfy_topic": "attacker-topic"}},
}
for _, c := range cases {
resp := call(c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
}
}
}
// Being refused other people's accounts must not cost a user their own.
func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "member")
self := "/api/users/" + id64(memberID)
resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("own notify target: %d", resp.StatusCode)
}
resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"})
resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Errorf("own password: %d", resp.StatusCode)
}
// An API key carries exactly the rights of its owner, so minting your own
// is no more than signing in again.
resp = call(http.MethodPost, self+"/api-keys", map[string]string{"name": "laptop"})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("own API key: %d", resp.StatusCode)
}
// And the queue still has to be able to name people.
resp = call(http.MethodGet, "/api/users", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("list users: %d", resp.StatusCode)
}
}
// Incident work is everybody's job; none of it is administration.
func TestAdmin_MemberCanWorkIncidents(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "responder")
postWebhook(t, s, []map[string]any{
amAlert("fp-admin", "DiskFull", "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
})
for _, c := range []struct {
name string
method string
path string
}{
{"list", http.MethodGet, "/api/incidents"},
{"acknowledge", http.MethodPost, "/api/incidents/1/acknowledge"},
{"resolve", http.MethodPost, "/api/incidents/1/resolve"},
} {
resp := call(c.method, c.path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("%s: expected 200, got %d", c.name, resp.StatusCode)
}
}
}
// An install must never be left with nobody who can administer it.
func TestAdmin_LastAdministratorIsProtected(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPut, "/api/users/1/admin", map[string]bool{"is_admin": false})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("self-demotion: expected 409, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, "/api/users/1", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("deleting yourself: expected 409, got %d", resp.StatusCode)
}
// With a second administrator the first may stand down, but not while they
// are the only one — which is the same rule from the other side.
otherID, _ := member(t, s, "second")
resp = s.req(t, http.MethodPut, "/api/users/"+id64(otherID)+"/admin", map[string]bool{"is_admin": true})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("granting admin: %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, "/api/users/"+id64(otherID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("deleting the second admin: expected 204, got %d", resp.StatusCode)
}
}
// A promoted user gets the powers with the flag, and loses them with it.
func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "promotee")
admin := "/api/users/" + id64(memberID) + "/admin"
resp := call(http.MethodPost, "/api/users", map[string]string{"username": "a", "email": "a@test.com"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("before the grant: %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": true})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("grant: %d", resp.StatusCode)
}
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "b", "email": "b@test.com"})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("after the grant: expected 201, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": false})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("revoke: %d", resp.StatusCode)
}
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "c", "email": "c@test.com"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("after the revoke: expected 403, got %d", resp.StatusCode)
}
}
// An administrator passes every team-owner check without being in the team,
// which is what lets them repair a team whose owner has left. It has been true
// since teams landed and nothing pinned it, so a later reading of the epic's
// "an admin is not implicitly in every team" could quietly take it away.
//
// The line it draws: configuring a team, yes; reading what the team owns, no.
// The queue below is the half that stays shut.
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
s := newTS(t)
// A team the admin is deliberately not a member of. It is created by
// somebody else, so the admin's only claim on it is the flag.
_, call := member(t, s, "founder")
var team struct {
ID int64 `json:"id"`
}
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
if team.ID == 0 {
t.Fatal("no team was created")
}
var mine []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
for _, m := range mine {
if m.ID == team.ID {
t.Fatalf("the admin should not be a member of team %d", team.ID)
}
}
path := "/api/teams/" + id64(team.ID)
for _, c := range []struct {
name string
method string
path string
body any
want int
}{
{"rename it", http.MethodPut, path,
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
{"mint an invite", http.MethodPost, path + "/invites",
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
{"add a member", http.MethodPost, path + "/members",
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
} {
resp := s.req(t, c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
// The other half of the rule. An incident in that team is not the admin's
// to read, because administration is about accounts — and the last case
// above has just taken the admin back out of the membership.
var integration struct {
Key string `json:"key"`
}
decode(t, call(http.MethodPost, path+"/integrations",
map[string]string{"name": "theirs alertmanager"}), &integration)
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
var incidents []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
if len(incidents) != 0 {
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
}
}
// The team page at /admin/teams/{id} needs the one question the test above
// leaves shut: who is in a team the administrator is not in.
//
// It is answered by a separate endpoint under AdminOnly rather than by letting
// the admin flag through requireTeamMember, and the second half of this test is
// the reason — /api/teams/{id}/members must keep answering 404, so that "member
// means membership and nothing else" stays true of the endpoint it was said
// about. Reading a team's shape and reading a team's work are different things.
func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) {
s := newTS(t)
founderID, call := member(t, s, "founder")
var team struct {
ID int64 `json:"id"`
}
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
if team.ID == 0 {
t.Fatal("no team was created")
}
// The admin reads it whole, without being in it.
var got struct {
Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
} `json:"team"`
Members []struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
} `json:"members"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
if got.Team.ID != team.ID || got.Team.Name != "theirs" {
t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name)
}
if got.Team.Members != 1 {
t.Errorf("expected a member count of 1, got %d", got.Team.Members)
}
if len(got.Members) != 1 {
t.Fatalf("expected one member, got %d", len(got.Members))
}
if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" {
t.Errorf("expected founder (%d), got %q (%d)",
founderID, got.Members[0].Username, got.Members[0].UserID)
}
// Whoever creates a team owns it, and the page's role toggle depends on
// that being reported rather than assumed.
if got.Members[0].Role != "owner" {
t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role)
}
// The rule this endpoint exists in order not to break. Same admin, same
// team, the member-only endpoint: still not found.
resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d",
resp.StatusCode)
}
// And the new one is administration, not membership: being in the team is
// not enough.
resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode)
}
for _, c := range []struct {
name string
path string
want int
}{
{"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound},
{"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest},
} {
resp := s.req(t, http.MethodGet, c.path, nil)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
}
// A team name is trimmed when it is created, and renaming had not been, so " "
// was a legal name to rename to and an illegal one to start with.
func TestRenameTeam_TrimsTheName(t *testing.T) {
s := newTS(t)
var team struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team)
path := "/api/teams/" + id64(team.ID)
resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a blank name must be refused, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
var got struct {
Team struct {
Name string `json:"name"`
} `json:"team"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
if got.Team.Name != "padded" {
t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name)
}
}
// The admin page's per-user view asks what somebody is in. Self or admin, like
// the rest of the per-user endpoints.
func TestUserTeams_SelfOrAdmin(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "joiner")
path := "/api/users/" + id64(memberID) + "/teams"
// member() puts them in the default team, so both readings agree on one.
for _, c := range []struct {
name string
do func() *http.Response
}{
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
} {
var teams []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
decode(t, c.do(), &teams)
if len(teams) != 1 {
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
}
if teams[0].Role != "member" {
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
}
}
// Somebody else's is not theirs to read.
otherID, _ := member(t, s, "nosy")
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
}
// A user who does not exist is a 404 rather than an empty list, which is
// how the page tells "no teams" from "no such person".
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
}
}
// The flag has to reach the client, or the web UI cannot decide what to show.
func TestAdmin_MeReportsTheFlag(t *testing.T) {
s := newTS(t)
var me struct {
User struct {
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
decode(t, s.req(t, http.MethodGet, "/api/me", nil), &me)
if !me.User.IsAdmin {
t.Error("the bootstrap user should be an administrator")
}
_, call := member(t, s, "plain")
var theirs struct {
User struct {
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
decode(t, call(http.MethodGet, "/api/me", nil), &theirs)
if theirs.User.IsAdmin {
t.Error("a created user should not be an administrator")
}
}
+80 -42
View File
@@ -4,9 +4,12 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// Values for alerts.resolution_source, recording why an alert left the firing
@@ -70,36 +73,63 @@ type ingested struct {
deadman bool
}
func handleAlertmanagerWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.HandlerFunc {
// handleIntegrationWebhook receives alerts on a team's own integration key.
// The key in the path is both the credential and the routing: it says who may
// post, and which team the alerts belong to.
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var payload amPayload
if err := decodeJSON(r, &payload); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload"))
teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key"))
if err != nil {
if errors.Is(err, errUnknownIntegration) {
// 401 and not 404: the path is real, the key is not, and a
// sender misconfigured this way should say so in its own logs
// rather than believe it is delivering.
respond(w, http.StatusUnauthorized, errResp("unknown integration key"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Alertmanager retries anything that is not 2xx, and a retry of a payload
// we failed to store is more useful than an error it cannot act on — so
// failures are logged, not surfaced.
if err := ingest(r.Context(), db, notify, deadman, payload); err != nil {
log.Printf("webhook ingest (group %q): %v", payload.GroupKey, err)
}
w.WriteHeader(http.StatusOK)
receiveWebhook(w, r, db, notify, teamID)
}
}
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
var payload amPayload
if err := decodeJSON(r, &payload); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload"))
return
}
// Alertmanager retries anything that is not 2xx, and a retry of a payload
// we failed to store is more useful than an error it cannot act on — so
// failures are logged, not surfaced.
if err := ingest(r.Context(), db, notify, teamID, payload); err != nil {
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
}
w.WriteHeader(http.StatusOK)
}
// ingest stores a payload's alerts and reconciles the incident for its group.
// The whole payload is one transaction: an incident that opened but whose alerts
// failed to link would be a work item nobody could act on.
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman DeadmanConfig, payload amPayload) error {
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64, payload amPayload) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
accepted, err := upsertAlerts(ctx, tx, deadman, payload.Alerts)
// Which arriving alerts are heartbeats is the team's own answer, read
// inside the transaction so an owner editing it mid-payload cannot split
// one webhook across two interpretations.
deadman, err := deadmanSetForTeam(ctx, tx, teamID)
if err != nil {
return err
}
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts)
if err != nil {
return err
}
@@ -108,7 +138,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma
// resolution cascade are recomputed once per incident at the end.
touched := map[int64]bool{}
incidentID, err := incidentForGroup(ctx, tx, notify, payload, accepted)
incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted)
if err != nil {
return err
}
@@ -156,7 +186,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
// the ordering guard rejected are left out entirely.
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts []amAlert) ([]ingested, error) {
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, teamID int64, alerts []amAlert) ([]ingested, error) {
now := time.Now().Unix()
accepted := make([]ingested, 0, len(alerts))
@@ -171,7 +201,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
var prevStartsAt int64
existed := true
switch err := tx.QueryRowContext(ctx,
"SELECT status, starts_at FROM alerts WHERE fingerprint = ?", a.Fingerprint,
"SELECT status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
teamID, a.Fingerprint,
).Scan(&prevStatus, &prevStartsAt); {
case err == sql.ErrNoRows:
existed = false
@@ -210,10 +241,10 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
// undone by a stale retry.
if _, err := tx.ExecContext(ctx, `
INSERT INTO alerts
(fingerprint, name, status, labels, annotations, starts_at, ends_at,
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
generator_url, received_at, resolution_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(fingerprint) DO UPDATE SET
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
status = excluded.status,
labels = excluded.labels,
annotations = excluded.annotations,
@@ -233,7 +264,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
OR (excluded.starts_at = alerts.starts_at
AND (alerts.resolution_source = '`+resolutionDeadman+`'
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
a.Fingerprint, name, a.Status,
teamID, a.Fingerprint, name, a.Status,
string(labelsJSON), string(annotationsJSON),
a.StartsAt.Unix(), endsAtUnix,
a.GeneratorURL, now, resolutionSource,
@@ -245,7 +276,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
var curStatus string
var curStartsAt int64
if err := tx.QueryRowContext(ctx,
"SELECT id, status, starts_at FROM alerts WHERE fingerprint = ?", a.Fingerprint,
"SELECT id, status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
teamID, a.Fingerprint,
).Scan(&id, &curStatus, &curStartsAt); err != nil {
return nil, err
}
@@ -284,7 +316,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
// Heartbeats do not count as anything here. A group of nothing but dead man's
// switch alerts opens no incident at all, and a mixed group gets an incident for
// its real alerts only.
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payload amPayload, accepted []ingested) (int64, error) {
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, teamID int64, payload amPayload, accepted []ingested) (int64, error) {
var firstName string
anyFiring, anyNew := false, false
for _, a := range accepted {
@@ -315,7 +347,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
var id int64
switch err := tx.QueryRowContext(ctx,
"SELECT id FROM incidents WHERE group_key = ? AND resolved_at IS NULL", groupKey,
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
teamID, groupKey,
).Scan(&id); {
case err == nil:
return id, nil
@@ -326,7 +359,7 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
if !anyNew {
return 0, nil
}
return openIncident(ctx, tx, notify, groupKey,
return openIncident(ctx, tx, notify, teamID, groupKey,
incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil)
}
@@ -338,8 +371,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
// column for refreshSeverity to fill from the member alerts; the sweeper passes
// one because its incidents have no members to derive it from.
func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
onCall, err := currentOnCall(ctx, q)
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
onCall, err := currentOnCall(ctx, q, teamID)
if err != nil {
return 0, err
}
@@ -349,15 +382,13 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
labelsJSON = []byte("{}")
}
res, err := q.ExecContext(ctx, `
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at, assigned_to)
VALUES (?, ?, ?, 'triggered', ?, ?, ?)`,
groupKey, title, string(labelsJSON), severity,
time.Now().Unix(), onCall)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
var id int64
err = q.QueryRowContext(ctx, `
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
RETURNING id`,
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
time.Now().Unix(), onCall).Scan(&id)
if err != nil {
return 0, err
}
@@ -372,12 +403,18 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
}
}
// Queue the page, but do not send it here: this runs inside a transaction on
// a single-connection pool, so an HTTP call would hold up every other
// request. The notifier picks the row up within a tick.
// Queue the page, but do not send it here: this runs inside the webhook's
// transaction, and an HTTP call would hold a connection open across a
// network round trip. The notifier picks the row up within a tick.
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
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
}
@@ -385,8 +422,9 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
// first time. Re-sends of an already-linked alert are silent.
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
res, err := tx.ExecContext(ctx, `
INSERT OR IGNORE INTO incident_alerts (incident_id, alert_id, added_at)
VALUES (?, ?, ?)`, incidentID, alertID, time.Now().Unix())
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
VALUES ($1, $2, $3)
ON CONFLICT (incident_id, alert_id) DO NOTHING`, incidentID, alertID, time.Now().Unix())
if err != nil {
return err
}
+23 -20
View File
@@ -19,7 +19,7 @@ import (
// incident_alerts rather than as a column here, because one alert row is reused
// across occurrences and belongs to a different incident each time.
const alertSelectFrom = `
SELECT a.id, a.fingerprint, a.name, a.status,
SELECT a.id, a.team_id, t.name, a.fingerprint, a.name, a.status,
a.labels, a.annotations,
a.starts_at, a.ends_at, a.generator_url, a.received_at,
(SELECT ia.incident_id
@@ -29,22 +29,28 @@ const alertSelectFrom = `
ORDER BY i.triggered_at DESC, i.id DESC
LIMIT 1),
a.resolution_source, a.archived_at
FROM alerts a`
FROM alerts a
JOIN teams t ON t.id = a.team_id`
func handleListAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
where := []string{}
args := []any{}
args := &sqlArgs{}
where = append(where, "a.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
if team := q.Get("team_id"); team != "" {
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
where = append(where, "a.team_id = "+args.add(n))
}
}
if status := q.Get("status"); status != "" {
where = append(where, "a.status = ?")
args = append(args, status)
where = append(where, "a.status = "+args.add(status))
}
if name := q.Get("name"); name != "" {
where = append(where, "a.name = ?")
args = append(args, name)
where = append(where, "a.name = "+args.add(name))
}
if archived := q.Get("archived"); archived == "true" {
where = append(where, "a.archived_at IS NOT NULL")
@@ -53,21 +59,18 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
}
if incidentID := q.Get("incident_id"); incidentID != "" {
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = ?)")
args = append(args, n)
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = "+args.add(n)+")")
}
}
if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
where = append(where, "a.received_at >= ?")
args = append(args, t.UTC().Unix())
where = append(where, "a.received_at >= "+args.add(t.UTC().Unix()))
}
}
if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
where = append(where, "a.received_at < ?")
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
where = append(where, "a.received_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
}
}
@@ -82,11 +85,10 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
if len(where) > 0 {
clause = strings.Join(where, " AND ")
}
args = append(args, limit)
rows, err := db.QueryContext(r.Context(),
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
args...)
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT %s", alertSelectFrom, clause, args.add(limit)),
args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -113,7 +115,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
return
}
a, err := fetchAlert(r.Context(), db, id)
a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context()))
if err == sql.ErrNoRows {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
@@ -127,8 +129,9 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
}
// fetchAlert loads a single alert by ID using the shared query.
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
func fetchAlert(ctx context.Context, db *sql.DB, id int64, teamIDs []int64) (models.Alert, error) {
return scanAlert(db.QueryRowContext(ctx,
alertSelectFrom+" WHERE a.id = $1 AND a.team_id = ANY($2)", id, teamIDs))
}
// scanner is satisfied by both *sql.Row and *sql.Rows.
@@ -143,7 +146,7 @@ func scanAlert(s scanner) (models.Alert, error) {
var endsAtUnix, archivedAtUnix *int64
if err := s.Scan(
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
&a.ID, &a.TeamID, &a.TeamName, &a.Fingerprint, &a.Name, &a.Status,
&labelsJSON, &annotationsJSON,
&startsAtUnix, &endsAtUnix,
&a.GeneratorURL, &receivedAtUnix,
+98 -45
View File
@@ -9,24 +9,29 @@ import (
"io"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
// tests can age rows directly — the sweeper's inputs are wall-clock timestamps.
type ts struct {
*httptest.Server
key string
db *sql.DB
notify api.NotifyConfig
deadman api.DeadmanConfig
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
notify api.NotifyConfig
deadman api.DeadmanConfig
}
// newTS builds a server over a fresh in-memory database. Notifications are off
// newTS builds a server over a fresh database. Notifications are off
// unless a NotifyConfig is passed, so tests that predate them are unaffected.
// Dead man's switches are off too — see newDeadmanTS.
func newTS(t *testing.T, notify ...api.NotifyConfig) *ts {
@@ -38,7 +43,7 @@ func newTS(t *testing.T, notify ...api.NotifyConfig) *ts {
return newDeadmanTS(t, api.DeadmanConfig{}, cfg)
}
// newDeadmanTS is newTS with dead man's switch handling configured.
// newDeadmanTS is newTS with the default team's dead man's switches configured.
func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyConfig) *ts {
t.Helper()
var cfg api.NotifyConfig
@@ -46,15 +51,9 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
cfg = notify[0]
}
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
srv := httptest.NewServer(api.NewRouter(database, cfg, deadman))
t.Cleanup(func() { srv.Close(); database.Close() })
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
@@ -69,7 +68,46 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
json.NewDecoder(resp.Body).Decode(&result)
key := result["api_key"].(map[string]any)["key"].(string)
return &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
// configures the default team the way an owner would.
if deadman.Timeout > 0 {
setTeamDeadman(t, s, deadman)
}
return s
}
// setTeamDeadman gives the default team one switch per configured matcher, over
// the API, the way an owner would add them.
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
t.Helper()
for _, m := range cfg.Matchers {
parts := []string{"alertname=" + m.Name}
for k, v := range m.Labels {
parts = append(parts, k+"="+v)
}
sort.Strings(parts[1:])
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
"matcher": strings.Join(parts, ","),
"timeout_seconds": int64(cfg.Timeout.Seconds()),
"severity": cfg.Severity,
})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add a dead man's switch: %d", resp.StatusCode)
}
}
}
// exec runs a statement against the test database.
@@ -84,7 +122,7 @@ func (s *ts) exec(t *testing.T, query string, args ...any) {
func (s *ts) alertRow(t *testing.T, fingerprint string) (status string, source *string, archivedAt *int64) {
t.Helper()
err := s.db.QueryRow(
"SELECT status, resolution_source, archived_at FROM alerts WHERE fingerprint = ?",
"SELECT status, resolution_source, archived_at FROM alerts WHERE fingerprint = $1",
fingerprint).Scan(&status, &source, &archivedAt)
if err != nil {
t.Fatalf("read alert %s: %v", fingerprint, err)
@@ -96,7 +134,7 @@ func (s *ts) alertRow(t *testing.T, fingerprint string) (status string, source *
func (s *ts) alertTimes(t *testing.T, fingerprint string) (startsAt, receivedAt int64) {
t.Helper()
err := s.db.QueryRow(
"SELECT starts_at, received_at FROM alerts WHERE fingerprint = ?",
"SELECT starts_at, received_at FROM alerts WHERE fingerprint = $1",
fingerprint).Scan(&startsAt, &receivedAt)
if err != nil {
t.Fatalf("read alert times %s: %v", fingerprint, err)
@@ -109,7 +147,7 @@ func (s *ts) alertEndsAt(t *testing.T, fingerprint string) *int64 {
t.Helper()
var endsAt *int64
if err := s.db.QueryRow(
"SELECT ends_at FROM alerts WHERE fingerprint = ?", fingerprint).Scan(&endsAt); err != nil {
"SELECT ends_at FROM alerts WHERE fingerprint = $1", fingerprint).Scan(&endsAt); err != nil {
t.Fatalf("read ends_at %s: %v", fingerprint, err)
}
return endsAt
@@ -208,7 +246,8 @@ func postWebhook(t *testing.T, s *ts, alerts []map[string]any, groupKey ...strin
}
}
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 {
t.Fatalf("post webhook: %v", err)
}
@@ -283,14 +322,14 @@ func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) {
func TestSchedule_ConflictOnSameDate(t *testing.T) {
s := newTS(t)
first := s.req(t, http.MethodPost, "/api/schedule",
first := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
if first.StatusCode != http.StatusCreated {
t.Fatalf("first assignment returned %d", first.StatusCode)
}
first.Body.Close()
second := s.req(t, http.MethodPost, "/api/schedule",
second := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
if second.StatusCode != http.StatusConflict {
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
@@ -302,11 +341,11 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
s := newTS(t)
// Claim 2026-06-10 first.
s.req(t, http.MethodPost, "/api/schedule",
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close()
// Try to assign two dates in one request where the second conflicts.
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode)
@@ -314,7 +353,7 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
resp.Body.Close()
// 2026-06-09 must NOT have been committed (transaction rolled back).
listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil)
listResp := s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-09&to=2026-06-09", nil)
var entries []any
decode(t, listResp, &entries)
if len(entries) != 0 {
@@ -328,21 +367,35 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
// addUser creates a second person to hand a shift to. The bootstrap user is
// admin, id 1.
// addUser creates a user and puts them in the default team, because a user who
// is in no team can be paged by nobody and take no shift — which is the rule
// these tests exercise around, not the one they are testing.
func addUser(t *testing.T, s *ts, username string) {
t.Helper()
resp := s.req(t, http.MethodPost, "/api/users",
map[string]any{"username": username, "email": username + "@test.com"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
resp.Body.Close()
t.Fatalf("create user returned %d", resp.StatusCode)
}
var user struct {
ID int64 `json:"id"`
}
decode(t, resp, &user)
member := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
defer member.Body.Close()
if member.StatusCode != http.StatusNoContent {
t.Fatalf("add %s to the team returned %d", username, member.StatusCode)
}
}
// scheduleHolder reports who is on call for one date, or "" for nobody.
func scheduleHolder(t *testing.T, s *ts, date string) string {
t.Helper()
var entries []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/schedule?from="+date+"&to="+date, nil), &entries)
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from="+date+"&to="+date, nil), &entries)
if len(entries) == 0 {
return ""
}
@@ -354,10 +407,10 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
s := newTS(t)
addUser(t, s, "alex")
s.req(t, http.MethodPost, "/api/schedule",
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}, "replace": true})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected replace to succeed, got %d", resp.StatusCode)
@@ -371,7 +424,7 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
// One row, not two: two entries for a date would mean two people believing
// they are on call for it.
var entries []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
if len(entries) != 1 {
t.Errorf("expected exactly one entry for the date, got %d", len(entries))
}
@@ -383,11 +436,11 @@ func TestSchedule_ReplaceMixedWeek(t *testing.T) {
s := newTS(t)
addUser(t, s, "alex")
s.req(t, http.MethodPost, "/api/schedule",
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-02", "2026-06-04"}}).Body.Close()
week := []string{"2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"}
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 2, "dates": week, "replace": true})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected the mixed week to succeed, got %d", resp.StatusCode)
@@ -406,10 +459,10 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) {
s := newTS(t)
addUser(t, s, "alex")
s.req(t, http.MethodPost, "/api/schedule",
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 without replace, got %d", resp.StatusCode)
@@ -427,7 +480,7 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) {
func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{"2026-06-01", "2026-06-01"}, "replace": true})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected a repeated date to be accepted under replace, got %d", resp.StatusCode)
@@ -435,7 +488,7 @@ func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
resp.Body.Close()
var entries []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
if len(entries) != 1 {
t.Errorf("expected one entry for the repeated date, got %d", len(entries))
}
@@ -505,7 +558,7 @@ func TestArchive_AlertListFilter(t *testing.T) {
}
// 2. Let the sweeper archive it: ends_at is already well past archiveAfter.
api.Sweep(context.Background(), s.db, time.Hour, 6*time.Hour, s.deadman, s.notify)
api.Sweep(context.Background(), s.db, time.Hour, 6*time.Hour, s.notify)
// 3. Default list excludes it.
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
@@ -546,7 +599,7 @@ func postAlert(t *testing.T, s *ts, fingerprint, status, startsAt, endsAt string
func sweep(t *testing.T, s *ts, staleAfter time.Duration) {
t.Helper()
api.Sweep(context.Background(), s.db, noArchive, staleAfter, s.deadman, s.notify)
api.Sweep(context.Background(), s.db, noArchive, staleAfter, s.notify)
}
// A firing alert Alertmanager stopped refreshing is resolved via the
@@ -556,7 +609,7 @@ func TestExpiry_StaleFiringAlert(t *testing.T) {
postAlert(t, s, "stale1", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
// Age the last-seen timestamp past the staleness window.
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'stale1'",
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'stale1'",
time.Now().Add(-10*time.Hour).Unix())
sweep(t, s, 6*time.Hour)
@@ -653,10 +706,10 @@ func TestWebhook_RefireUnarchivesAndClearsSource(t *testing.T) {
postAlert(t, s, "refire1", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
// Expire it, then archive it.
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'refire1'",
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'refire1'",
time.Now().Add(-10*time.Hour).Unix())
sweep(t, s, 6*time.Hour)
s.exec(t, "UPDATE alerts SET archived_at = unixepoch() WHERE fingerprint = 'refire1'")
s.exec(t, "UPDATE alerts SET archived_at = FLOOR(EXTRACT(EPOCH FROM now()))::bigint WHERE fingerprint = 'refire1'")
var alerts []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
@@ -714,7 +767,7 @@ func TestExpiry_EndsAtIsUpperBound(t *testing.T) {
// No watermark: expires on the received_at heartbeat, so the sweeper has
// nothing to go on but its own clock.
postAlert(t, s, "ub-none", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'ub-none'",
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'ub-none'",
time.Now().Add(-10*time.Hour).Unix())
// Stale watermark: expires on the ends_at branch, and that reported time
@@ -768,7 +821,7 @@ func TestWebhook_ResendBumpsReceivedAt(t *testing.T) {
// received_at has one-second granularity, so back-date it to make the bump
// observable instead of sleeping out a second.
aged := time.Now().Add(-2 * time.Hour).Unix()
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'beat1'", aged)
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'beat1'", aged)
// Identical re-send: same fingerprint, same startsAt, still firing.
postAlert(t, s, "beat1", "firing", start, zeroTime)
@@ -796,7 +849,7 @@ func TestWebhook_DiscardedRetryLeavesReceivedAtAlone(t *testing.T) {
postAlert(t, s, "beat2", "resolved", start, time.Now().Format(time.RFC3339))
aged := time.Now().Add(-2 * time.Hour).Unix()
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'beat2'", aged)
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'beat2'", aged)
postAlert(t, s, "beat2", "firing", start, zeroTime) // stale retry, discarded
+30 -25
View File
@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"log"
"strings"
"time"
)
@@ -19,15 +19,19 @@ const (
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
// immediate pass so a restart reconciles state right away.
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, deadman DeadmanConfig, notify NotifyConfig) {
// 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) {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
Sweep(ctx, db, archiveAfter, staleAfter, deadman, notify)
Sweep(ctx, db, archiveAfter, staleAfter, notify)
for {
select {
case <-ticker.C:
Sweep(ctx, db, archiveAfter, staleAfter, deadman, notify)
Sweep(ctx, db, archiveAfter, staleAfter, notify)
case <-ctx.Done():
return
}
@@ -44,8 +48,12 @@ func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter tim
// touch: a heartbeat answers to its own, much tighter, timeout, and the generic
// staleness rules would otherwise resolve it as 'expiry' long before that.
// Exported so tests can drive a pass without waiting on the ticker.
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, deadman DeadmanConfig, notify NotifyConfig) {
heartbeats := sweepDeadman(ctx, db, deadman, notify)
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)
expireStale(ctx, db, staleAfter, heartbeats)
resolveSettledIncidents(ctx, db)
archiveResolved(ctx, db, archiveAfter)
@@ -91,17 +99,18 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
return
}
args := make([]any, 0, len(ids)+1)
args = append(args, resolutionExpiry)
for _, id := range ids {
args = append(args, id)
args := &sqlArgs{}
source := args.add(resolutionExpiry)
idList := make([]any, len(ids))
for i, id := range ids {
idList[i] = id
}
if _, err := db.ExecContext(ctx, `
UPDATE alerts
SET status = 'resolved',
resolution_source = ?,
ends_at = COALESCE(ends_at, unixepoch())
WHERE id IN (`+placeholders(len(ids))+`)`, args...); err != nil {
resolution_source = `+source+`,
ends_at = COALESCE(ends_at, `+nowEpoch+`)
WHERE id IN (`+args.addList(idList)+`)`, args.all()...); err != nil {
log.Printf("sweeper: expire stale: %v", err)
return
}
@@ -124,14 +133,15 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
}
// staleAlertIDs reads the ids in one go and closes the cursor before the caller
// writes: the pool is limited to a single connection, so an open read would
// block the update behind it.
// writes. Under SQLite's single connection an open read would have blocked the
// update outright; with a pool it is no longer a deadlock, but reading the set
// first still keeps the write off a cursor the same transaction is walking.
func staleAlertIDs(ctx context.Context, db *sql.DB, now time.Time, staleAfter time.Duration) ([]int64, error) {
rows, err := db.QueryContext(ctx, `
SELECT id FROM alerts
WHERE status = 'firing'
AND archived_at IS NULL
AND ((ends_at IS NOT NULL AND ends_at < ?) OR received_at < ?)`,
AND ((ends_at IS NOT NULL AND ends_at < $1) OR received_at < $2)`,
now.Add(-expiryGrace).Unix(), now.Add(-staleAfter).Unix())
if err != nil {
return nil, err
@@ -206,10 +216,10 @@ func settledIncidentIDs(ctx context.Context, db *sql.DB) ([]int64, error) {
func archiveResolved(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
cutoff := time.Now().Add(-archiveAfter).Unix()
res, err := db.ExecContext(ctx,
`UPDATE alerts SET archived_at = unixepoch()
`UPDATE alerts SET archived_at = `+nowEpoch+`
WHERE status = 'resolved'
AND archived_at IS NULL
AND COALESCE(ends_at, received_at) < ?`, cutoff)
AND COALESCE(ends_at, received_at) < $1`, cutoff)
if err != nil {
log.Printf("archiver: %v", err)
return
@@ -223,10 +233,10 @@ func archiveResolved(ctx context.Context, db *sql.DB, archiveAfter time.Duration
func archiveResolvedIncidents(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
cutoff := time.Now().Add(-archiveAfter).Unix()
res, err := db.ExecContext(ctx,
`UPDATE incidents SET archived_at = unixepoch()
`UPDATE incidents SET archived_at = `+nowEpoch+`
WHERE resolved_at IS NOT NULL
AND archived_at IS NULL
AND resolved_at < ?`, cutoff)
AND resolved_at < $1`, cutoff)
if err != nil {
log.Printf("archiver: incidents: %v", err)
return
@@ -235,8 +245,3 @@ func archiveResolvedIncidents(ctx context.Context, db *sql.DB, archiveAfter time
log.Printf("archiver: archived %d resolved incident(s)", n)
}
}
// placeholders builds "?, ?, …" for an IN clause of n values.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?, ", n), ", ")
}
+54 -30
View File
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
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.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -163,7 +191,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
var userID int64
var hash sql.NullString
err := db.QueryRowContext(r.Context(),
"SELECT id, password_hash FROM users WHERE username = ?", username,
"SELECT id, password_hash FROM users WHERE username = $1", username,
).Scan(&userID, &hash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
}
limiter.clear(userKey)
raw, tokenHash, err := randomToken()
if err != nil {
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
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 (?, ?, ?, ?, ?, ?)`,
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)
if err != nil {
@@ -225,7 +234,7 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
return
}
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = ?", hashToken(c.Value))
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
type meResponse struct {
User any `json:"user"`
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
@@ -256,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
return
}
var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = ?", caller.ID).Scan(&hash)
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
}
}
@@ -266,8 +286,9 @@ func handleMe(db *sql.DB) http.HandlerFunc {
//
// Changing your own password takes the current one, when there is one, so an
// unattended signed-in browser cannot be used to take the account over. Setting
// somebody else's is how an admin gives a user their first password, and like
// the other user endpoints it is open to any authenticated caller.
// somebody else's is how an admin gives a user their first password, and is
// restricted to administrators: it hands over an account outright, without
// knowing the password it replaces.
//
// Every other session of the target is ended: a password change is what you
// do when you think someone else is signed in.
@@ -278,6 +299,9 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password"`
@@ -293,7 +317,7 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
var existing sql.NullString
err = db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = ?", id).Scan(&existing)
"SELECT password_hash FROM users WHERE id = $1", id).Scan(&existing)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("user not found"))
return
@@ -324,13 +348,13 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(),
"UPDATE users SET password_hash = ? WHERE id = ?", hash, id); err != nil {
"UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM sessions WHERE user_id = ? AND id != ?", id, keep); err != nil {
"DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -345,7 +369,7 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
// purgeSessions deletes sessions that have expired, from the sweeper.
func purgeSessions(ctx context.Context, db *sql.DB) {
res, err := db.ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix())
"DELETE FROM sessions WHERE expires_at < $1", time.Now().Unix())
if err != nil {
log.Printf("sweeper: purge sessions: %v", err)
return
+4 -11
View File
@@ -11,7 +11,6 @@ import (
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
const adminPassword = "correct horse battery"
@@ -249,7 +248,7 @@ func TestSession_ExpiredIsRejected(t *testing.T) {
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
t.Errorf("expired session: %d", code)
}
api.Sweep(t.Context(), s.db, 0, 0, api.DeadmanConfig{}, api.NotifyConfig{})
api.Sweep(t.Context(), s.db, 0, 0, api.NotifyConfig{})
var n int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
if n != 0 {
@@ -301,15 +300,9 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
}
func TestBootstrap_WithPassword(t *testing.T) {
database, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(database); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, api.DeadmanConfig{}))
t.Cleanup(func() { srv.Close(); database.Close() })
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
t.Cleanup(srv.Close)
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", strings.NewReader(body))
+409 -105
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"strings"
@@ -39,6 +41,17 @@ func (m DeadmanMatcher) String() string {
return m.Name + " (" + strings.Join(parts, ", ") + ")"
}
// config renders the matcher in the form parseDeadmanMatcher reads, which is
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
func (m DeadmanMatcher) config() string {
parts := make([]string, 0, len(m.Labels))
for k, v := range m.Labels {
parts = append(parts, k+"="+v)
}
sort.Strings(parts)
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
}
// matches reports whether an alert's labels satisfy every condition.
func (m DeadmanMatcher) matches(labels map[string]string) bool {
if labels["alertname"] != m.Name {
@@ -52,12 +65,10 @@ func (m DeadmanMatcher) matches(labels map[string]string) bool {
return true
}
// DeadmanConfig inverts the handling of the alerts it matches: receiving one
// opens nothing, and the absence of one opens an incident.
//
// The unit of monitoring is the fingerprint, not the matcher — two clusters
// sending the same heartbeat alertname are two independent switches, so one
// healthy cluster cannot mask a dead one.
// DeadmanConfig is the server-wide default a team's switches are seeded from:
// the environment's matchers, timeout and severity. Switches themselves are rows
// of a team's own — see DeadmanSwitch — and this is only how a fresh install
// starts out.
type DeadmanConfig struct {
Matchers []DeadmanMatcher
@@ -76,41 +87,81 @@ type DeadmanConfig struct {
// enabled reports whether there is anything to watch.
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
// match returns the first matcher an alert satisfies.
func (c DeadmanConfig) match(labels map[string]string) (DeadmanMatcher, bool) {
if !c.enabled() {
return DeadmanMatcher{}, false
}
for _, m := range c.Matchers {
if m.matches(labels) {
return m, true
}
}
return DeadmanMatcher{}, false
// DeadmanSwitch inverts the handling of the alerts it matches: receiving one
// opens nothing, and the absence of one opens an incident.
//
// The unit of monitoring is the fingerprint, not the switch — two clusters
// sending the same heartbeat alertname are two independent heartbeats under one
// switch, so one healthy cluster cannot mask a dead one.
type DeadmanSwitch struct {
ID int64
Name string
Matcher DeadmanMatcher
// Timeout is how long a heartbeat may go unheard before it is declared dead.
Timeout time.Duration
// Severity is what the incident opens at.
Severity string
}
// isDeadman is match without the matcher, for the ingest path.
func (c DeadmanConfig) isDeadman(labels map[string]string) bool {
_, ok := c.match(labels)
// deadmanSet is one team's switches.
type deadmanSet []DeadmanSwitch
// match returns the first switch an alert satisfies.
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
for _, sw := range d {
if sw.Matcher.matches(labels) {
return sw, true
}
}
return DeadmanSwitch{}, false
}
// isDeadman is match without the switch, for the ingest path.
func (d deadmanSet) isDeadman(labels map[string]string) bool {
_, ok := d.match(labels)
return ok
}
// names lists the distinct alertnames worth loading from the database.
func (c DeadmanConfig) names() []string {
func (d deadmanSet) names() []string {
seen := map[string]bool{}
out := make([]string, 0, len(c.Matchers))
for _, m := range c.Matchers {
if !seen[m.Name] {
seen[m.Name] = true
out = append(out, m.Name)
out := make([]string, 0, len(d))
for _, sw := range d {
if !seen[sw.Matcher.Name] {
seen[sw.Matcher.Name] = true
out = append(out, sw.Matcher.Name)
}
}
return out
}
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
// The error says what is wrong with it, in words a form can show.
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
m := DeadmanMatcher{Labels: map[string]string{}}
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if m.Name == "" {
return DeadmanMatcher{}, errors.New("no alertname condition")
}
return m, nil
}
// ParseDeadmanConfig reads the matcher list from its configured form:
// ";" separates matchers, "," separates the conditions within one, and "=" is
// exact label equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`.
// ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
//
// A malformed or alertname-less entry is dropped rather than fatal, following
// config.duration's rule that one bad tuning knob should not take the server
@@ -125,28 +176,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
if entry == "" {
continue
}
m := DeadmanMatcher{Labels: map[string]string{}}
malformed := false
for _, cond := range strings.Split(entry, ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
log.Printf("deadman: ignoring matcher %q: %q is not label=value", entry, strings.TrimSpace(cond))
malformed = true
break
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if malformed {
continue
}
if m.Name == "" {
log.Printf("deadman: ignoring matcher %q: no alertname condition", entry)
m, err := parseDeadmanMatcher(entry)
if err != nil {
log.Printf("deadman: ignoring matcher %q: %v", entry, err)
continue
}
cfg.Matchers = append(cfg.Matchers, m)
@@ -162,22 +194,35 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
for _, m := range cfg.Matchers {
rendered = append(rendered, m.String())
}
log.Printf("deadman: watching %s, timeout %s, severity %s",
log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
strings.Join(rendered, "; "), timeout, severity)
}
return cfg
}
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
// deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
// the switch that claimed it.
type deadmanAlert struct {
id int64
teamID int64
fingerprint string
labels map[string]string
matcher DeadmanMatcher
sw DeadmanSwitch
resolved bool
receivedAt int64
}
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
// on it and the status the Switches page shows, so the page cannot disagree
// with the pager.
//
// An explicit resolved from Alertmanager is a stronger death signal than mere
// absence: the sender is telling us the heartbeat stopped, so there is nothing
// left to wait out.
func (a deadmanAlert) dead(now time.Time) bool {
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
}
// groupKey is the switch's identity as an incident. Per fingerprint, so each
// source is tracked on its own.
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
@@ -188,56 +233,62 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
// It returns the ids of the alerts it owns, because the generic staleness
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
// a heartbeat long before its own, much tighter, timeout ever fired.
func sweepDeadman(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig) map[int64]bool {
// Each team is swept against its own switches, each with its own matcher,
// timeout and severity. A team watching nothing is skipped entirely, which is
// most of them.
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
owned := map[int64]bool{}
if !cfg.enabled() {
return owned
}
switches, err := deadmanAlerts(ctx, db, cfg)
configs, err := deadmanSets(ctx, db)
if err != nil {
log.Printf("deadman: load switches: %v", err)
log.Printf("deadman: load configs: %v", err)
return owned
}
now := time.Now()
cutoff := now.Add(-cfg.Timeout).Unix()
for _, sw := range switches {
owned[sw.id] = true
// An explicit resolved from Alertmanager is a stronger death signal than
// mere absence: the sender is telling us the heartbeat stopped, so there
// is nothing left to wait out.
if sw.resolved || sw.receivedAt < cutoff {
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
}
for teamID, cfg := range configs {
heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
if err != nil {
log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
continue
}
if err := deadmanRecovered(ctx, db, sw); err != nil {
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err)
for _, hb := range heartbeats {
owned[hb.id] = true
if hb.dead(now) {
if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
}
continue
}
if err := deadmanRecovered(ctx, db, hb); err != nil {
log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
}
}
}
return owned
}
// deadmanAlerts loads every alert row that a matcher claims. The candidate query
// deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
// happens in Go, which keeps one implementation of the rules. The rows are read
// in full before the caller writes, because the pool holds a single connection.
func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadmanAlert, error) {
// in full before the caller writes, so the writes do not run against an open
// cursor over the same table.
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
names := cfg.names()
args := make([]any, 0, len(names))
for _, n := range names {
args = append(args, n)
args := &sqlArgs{}
nameList := make([]any, len(names))
for i, n := range names {
nameList[i] = n
}
rows, err := db.QueryContext(ctx, `
SELECT id, fingerprint, labels, status, received_at
SELECT id, team_id, fingerprint, labels, status, received_at
FROM alerts
WHERE name IN (`+placeholders(len(names))+`)
AND archived_at IS NULL`, args...)
WHERE team_id = `+args.add(teamID)+`
AND name IN (`+args.addList(nameList)+`)
AND archived_at IS NULL`, args.all()...)
if err != nil {
return nil, err
}
@@ -247,16 +298,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma
for rows.Next() {
var a deadmanAlert
var labelsJSON, status string
if err := rows.Scan(&a.id, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil {
if err := rows.Scan(&a.id, &a.teamID, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil {
return nil, err
}
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
m, ok := cfg.match(a.labels)
sw, ok := cfg.match(a.labels)
if !ok {
continue
}
a.matcher = m
a.sw = sw
a.resolved = status == "resolved"
out = append(out, a)
}
@@ -273,16 +324,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma
// incidentForGroup), and a source that is gone for good is a one-time page
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
// incident.
func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig, sw deadmanAlert, now time.Time) error {
func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
var lastTriggered, open int64
if err := db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(triggered_at), 0),
COALESCE(SUM(resolved_at IS NULL), 0)
FROM incidents WHERE group_key = ?`,
sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
COUNT(*) FILTER (WHERE resolved_at IS NULL)
FROM incidents WHERE team_id = $1 AND group_key = $2`,
hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
return err
}
if open > 0 || sw.receivedAt <= lastTriggered {
if open > 0 || hb.receivedAt <= lastTriggered {
return nil
}
@@ -295,31 +346,33 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// A heartbeat nobody has heard from is not firing, and saying otherwise in
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
// own source: it told us the truth first.
if !sw.resolved {
if !hb.resolved {
if _, err := tx.ExecContext(ctx, `
UPDATE alerts
SET status = 'resolved',
resolution_source = ?,
ends_at = COALESCE(ends_at, unixepoch())
WHERE id = ? AND status = 'firing'`, resolutionDeadman, sw.id); err != nil {
resolution_source = $1,
ends_at = COALESCE(ends_at, `+nowEpoch+`)
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
return err
}
}
severity := cfg.Severity
severity := hb.sw.Severity
var sev *string
if severity != "" {
sev = &severity
}
incidentID, err := openIncident(ctx, tx, notify, sw.groupKey(),
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
// The incident opens in the team whose integration received the heartbeat:
// the switch belongs to whoever is watching that source, not to the install.
incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
"No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
if err != nil {
return err
}
alertID := sw.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(sw.receivedAt, 0))) + " ago"
alertID := hb.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
return err
}
@@ -327,7 +380,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
if err := tx.Commit(); err != nil {
return err
}
log.Printf("deadman: %s went silent, opened incident %d", sw.matcher.String(), incidentID)
log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
return nil
}
@@ -337,11 +390,12 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// member alerts (linking the heartbeat would have the settled-incident cascade
// close it on the very same sweep that opened it), so the alert-driven cascade
// ignores it entirely and recovery is the only automatic way out.
func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
var incidentID int64
switch err := db.QueryRowContext(ctx, `
SELECT id FROM incidents
WHERE group_key = ? AND resolved_at IS NULL`, sw.groupKey()).Scan(&incidentID); {
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
hb.teamID, hb.groupKey()).Scan(&incidentID); {
case err == sql.ErrNoRows:
return nil
case err != nil:
@@ -356,8 +410,8 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
if _, err := tx.ExecContext(ctx, `
UPDATE incidents
SET status = 'resolved', resolved_at = ?, resolution_source = ?
WHERE id = ? AND resolved_at IS NULL`,
SET status = 'resolved', resolved_at = $1, resolution_source = $2
WHERE id = $3 AND resolved_at IS NULL`,
time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil {
return err
}
@@ -373,6 +427,256 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
if err := tx.Commit(); err != nil {
return err
}
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID)
log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
return nil
}
// ---------------------------------------------------------------------------
// A team's switches
// ---------------------------------------------------------------------------
const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
// scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
// no longer parses is skipped rather than fatal: the API refuses to store one,
// so it can only mean a hand edit, and one bad row must not stop the others
// from being watched.
func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
defer rows.Close()
out := map[int64]deadmanSet{}
for rows.Next() {
var sw DeadmanSwitch
var teamID, timeout int64
var matcher string
if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
return nil, err
}
m, err := parseDeadmanMatcher(matcher)
if err != nil {
log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
continue
}
sw.Matcher = m
sw.Timeout = time.Duration(timeout) * time.Second
out[teamID] = append(out[teamID], sw)
}
return out, rows.Err()
}
// deadmanSetForTeam reads one team's switches. A team with none gets an empty
// set — which is the right answer rather than an error: most teams watch no
// heartbeat at all.
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
rows, err := q.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
if err != nil {
return nil, err
}
sets, err := scanDeadmanSwitches(rows)
return sets[teamID], err
}
// deadmanSets reads every team's switches in one query, for the sweeper.
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
rows, err := db.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
if err != nil {
return nil, err
}
return scanDeadmanSwitches(rows)
}
// deadmanSeededKey is the settings row that records the environment defaults
// were handed out. Without it, a team that deleted its last switch would get
// the default back on the next restart.
const deadmanSeededKey = "deadman_seeded"
// SeedDeadmanConfigs gives every team the server's environment defaults as
// switches, exactly once per install, so a fresh install watches Watchdog
// without anybody setting it up.
//
// Once seeded it never runs again: a team's switches are its own, and a redeploy
// must not quietly put the environment's value back over an owner's edit or
// deletion. Installs that upgraded from per-team configuration were already
// seeded, which migration 009 records.
//
// A team created after that gets none and watches nothing until its owner says
// otherwise. That is deliberate: inheriting an install-wide heartbeat would page
// a new team about a source it has never heard of, and a switch nobody chose is
// the kind that gets muted rather than fixed.
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
if !cfg.enabled() {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
res, err := tx.ExecContext(ctx,
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
deadmanSeededKey)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
for _, m := range cfg.Matchers {
if _, err := tx.ExecContext(ctx, `
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
SELECT id, $1, $1, $2, $3 FROM teams`,
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
return err
}
}
return tx.Commit()
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
const (
switchHealthy = "healthy"
switchDead = "dead"
switchDormant = "dormant"
)
// deadmanSource is one heartbeat under a switch: a fingerprint that matched.
type deadmanSource struct {
Fingerprint string `json:"fingerprint"`
Labels map[string]string `json:"labels"`
Status string `json:"status"`
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
IncidentID *int64 `json:"incident_id"`
}
// deadmanSwitchStatus is a switch as the Switches page shows it.
type deadmanSwitchStatus struct {
ID int64 `json:"id"`
Name string `json:"name"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
// Status is dead when any source is, dormant when none has ever been heard
// from, healthy otherwise — a live cluster must not hide a dead one.
Status string `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
OpenIncidentID *int64 `json:"open_incident_id"`
Sources []deadmanSource `json:"sources"`
}
// deadmanStatuses reports every switch of a team with what its heartbeats are
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
out := make([]deadmanSwitchStatus, 0, len(set))
if len(set) == 0 {
return out, nil
}
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
if err != nil {
return nil, err
}
// One query for every switch's incident history, keyed the way the sweeper
// keys it.
type history struct {
triggeredAt int64
openID int64
}
incidents := map[string]history{}
rows, err := db.QueryContext(ctx, `
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
FROM incidents
WHERE team_id = $1 AND group_key LIKE $2
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key string
var h history
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
return nil, err
}
incidents[key] = h
}
if err := rows.Err(); err != nil {
return nil, err
}
bySwitch := map[int64][]deadmanAlert{}
for _, hb := range heartbeats {
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
}
later := func(cur *time.Time, unix int64) *time.Time {
t := time.Unix(unix, 0).UTC()
if cur == nil || t.After(*cur) {
return &t
}
return cur
}
for _, sw := range set {
st := deadmanSwitchStatus{
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
Status: switchDormant, Sources: []deadmanSource{},
}
for _, hb := range bySwitch[sw.ID] {
src := deadmanSource{
Fingerprint: hb.fingerprint,
Labels: hb.labels,
Status: switchHealthy,
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
}
if hb.dead(now) {
src.Status = switchDead
}
if h, ok := incidents[hb.groupKey()]; ok {
t := time.Unix(h.triggeredAt, 0).UTC()
src.LastTriggeredAt = &t
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
if h.openID != 0 {
id := h.openID
src.IncidentID = &id
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
st.OpenIncidentID = &id
}
}
}
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
st.Sources = append(st.Sources, src)
switch {
case src.Status == switchDead:
st.Status = switchDead
case st.Status == switchDormant:
st.Status = switchHealthy
}
}
// Dead ones first, then by fingerprint: what needs attention leads, and
// the order does not shuffle between refreshes.
sort.Slice(st.Sources, func(i, j int) bool {
a, b := st.Sources[i], st.Sources[j]
if (a.Status == switchDead) != (b.Status == switchDead) {
return a.Status == switchDead
}
return a.Fingerprint < b.Fingerprint
})
out = append(out, st)
}
return out, nil
}
+278 -4
View File
@@ -1,7 +1,9 @@
package api_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
@@ -51,7 +53,7 @@ func heartbeat(t *testing.T, s *ts, fingerprint string, labels map[string]string
// sweeper reads. There is no fake clock in this package.
func silence(t *testing.T, s *ts, fingerprint string, ago time.Duration) {
t.Helper()
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = ?",
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
time.Now().Add(-ago).Unix(), fingerprint)
}
@@ -61,9 +63,12 @@ func silence(t *testing.T, s *ts, fingerprint string, ago time.Duration) {
func ageIncidents(t *testing.T, s *ts, ago time.Duration) {
t.Helper()
past := time.Now().Add(-ago).Unix()
// $2 is cast explicitly: with NULL in the other branch Postgres has nothing
// to infer the parameter's type from and defaults it to text, which the
// bigint column then refuses.
s.exec(t, `UPDATE incidents
SET triggered_at = ?,
resolved_at = CASE WHEN resolved_at IS NULL THEN NULL ELSE ? END`,
SET triggered_at = $1,
resolved_at = CASE WHEN resolved_at IS NULL THEN NULL ELSE $2::bigint END`,
past, past)
}
@@ -72,7 +77,7 @@ func incidentByGroup(t *testing.T, s *ts, groupKey string) (id int64, status, se
t.Helper()
err := s.db.QueryRow(`
SELECT id, status, COALESCE(severity, ''), resolution_source
FROM incidents WHERE group_key = ? ORDER BY id DESC LIMIT 1`,
FROM incidents WHERE group_key = $1 ORDER BY id DESC LIMIT 1`,
groupKey).Scan(&id, &status, &severity, &source)
if err != nil {
t.Fatalf("read incident for group %s: %v", groupKey, err)
@@ -466,3 +471,272 @@ func TestDeadman_DisabledConfigIsInert(t *testing.T) {
t.Errorf("expected the generic sweeper to own the alert, got %v", source)
}
}
// ---------------------------------------------------------------------------
// Per-team configuration
// ---------------------------------------------------------------------------
// Each team decides for itself what a heartbeat is. The same alert is a
// heartbeat in one team and an ordinary problem in another.
func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
watched := newTeam(t, s, "watched")
unwatched := newTeam(t, s, "unwatched")
// Only the first team calls Watchdog a heartbeat.
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(watched.id)+"/deadman/switches", map[string]any{
"matcher": "alertname=Watchdog",
"timeout_seconds": 3600,
"severity": "critical",
})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("configure the watched team: %d", resp.StatusCode)
}
postToIntegration(t, s, watched.key, "fp-watched", "Watchdog")
postToIntegration(t, s, unwatched.key, "fp-unwatched", "Watchdog")
// A heartbeat opens nothing where it is one; an ordinary alert opens an
// incident where it is not.
if got := len(list(t, watched.call(http.MethodGet, "/api/incidents", nil))); got != 0 {
t.Errorf("the watched team's heartbeat opened %d incident(s), want 0", got)
}
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
t.Errorf("the unwatched team's Watchdog opened %d incident(s), want 1", got)
}
// Silence pages only the team that is watching.
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
time.Now().Add(-2*time.Hour).Unix(), "fp-watched")
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
time.Now().Add(-2*time.Hour).Unix(), "fp-unwatched")
sweep(t, s, noArchive)
watchedIncidents := list(t, watched.call(http.MethodGet, "/api/incidents", nil))
if len(watchedIncidents) != 1 {
t.Fatalf("silence opened %d incident(s) for the watching team, want 1", len(watchedIncidents))
}
if title := watchedIncidents[0]["title"].(string); title != "No heartbeat from Watchdog" {
t.Errorf("unexpected incident title %q", title)
}
if teamID := int64(watchedIncidents[0]["team_id"].(float64)); teamID != watched.id {
t.Errorf("the incident opened in team %d, want %d", teamID, watched.id)
}
// The unwatched team's alert went stale the ordinary way, so it has the one
// incident it always had — not a second, dead man's switch one.
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
t.Errorf("the unwatched team ended with %d incident(s), want 1", got)
}
}
// Configuration is an owner's to change and a member's to read, like the rest of
// a team's settings.
func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
team := newTeam(t, s, "red")
// A plain member of that team.
var user struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
req, _ := http.NewRequest(http.MethodPost,
s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches",
strings.NewReader(`{"matcher":"alertname=Watchdog","timeout_seconds":60}`))
req.Header.Set("Authorization", "Bearer "+key.Key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("put: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
}
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches", nil)
read.Header.Set("Authorization", "Bearer "+key.Key)
got, err := http.DefaultClient.Do(read)
if err != nil {
t.Fatalf("get: %v", err)
}
got.Body.Close()
if got.StatusCode != http.StatusOK {
t.Errorf("a member reading the switches: expected 200, got %d", got.StatusCode)
}
}
// A matcher with no alertname watches nothing, silently, which is the failure
// this feature exists to prevent — so it is refused at the door, along with the
// other things that would make a switch unable to fire.
func TestDeadman_UnusableSwitchesAreRejected(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
for name, body := range map[string]map[string]any{
"no alertname": {"matcher": "cluster=prod", "timeout_seconds": 900},
"malformed": {"matcher": "alertname=Watchdog,garbage", "timeout_seconds": 900},
"several": {"matcher": "alertname=A; alertname=B", "timeout_seconds": 900},
"zero timeout": {"matcher": "alertname=Watchdog", "timeout_seconds": 0},
"bad severity": {"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "loud"},
"empty matcher": {"matcher": "", "timeout_seconds": 900},
} {
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d", name, resp.StatusCode)
}
}
}
// ---------------------------------------------------------------------------
// The switch list
// ---------------------------------------------------------------------------
// listSwitches reads the default team's switches as the Switches page does.
func listSwitches(t *testing.T, s *ts) []map[string]any {
t.Helper()
return list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil))
}
// A switch is healthy while its heartbeat is fresh, dead once it is silent, and
// dormant until the first one arrives.
func TestDeadman_ListReportsStatus(t *testing.T) {
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog; alertname=NeverSent", time.Hour, "critical"))
got := listSwitches(t, s)
if len(got) != 2 {
t.Fatalf("expected 2 switches, got %d", len(got))
}
for _, sw := range got {
if sw["status"] != "dormant" || sw["last_heartbeat_at"] != nil || sw["last_triggered_at"] != nil {
t.Errorf("a switch nobody has heard from should be dormant and blank, got %v", sw)
}
}
heartbeat(t, s, "fp-watchdog", nil)
got = listSwitches(t, s)
if got[0]["status"] != "healthy" || got[0]["last_heartbeat_at"] == nil {
t.Errorf("a fresh heartbeat should be healthy with a timestamp, got %v", got[0])
}
if got[1]["status"] != "dormant" {
t.Errorf("the other switch is still dormant, got %v", got[1]["status"])
}
silence(t, s, "fp-watchdog", 2*time.Hour)
sweep(t, s, noArchive)
got = listSwitches(t, s)
if got[0]["status"] != "dead" {
t.Fatalf("a silent heartbeat should be dead, got %v", got[0]["status"])
}
if got[0]["last_triggered_at"] == nil || got[0]["open_incident_id"] == nil {
t.Errorf("a dead switch should show when it triggered and its open incident, got %v", got[0])
}
}
// One matcher, several clusters: the switch is as bad as its worst heartbeat and
// each heartbeat is listed on its own.
func TestDeadman_ListBreaksDownByFingerprint(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
silence(t, s, "fp-b", 2*time.Hour)
sw := listSwitches(t, s)[0]
if sw["status"] != "dead" {
t.Errorf("one dead cluster makes the switch dead, got %v", sw["status"])
}
sources := sw["sources"].([]any)
if len(sources) != 2 {
t.Fatalf("expected 2 sources, got %d", len(sources))
}
first, second := sources[0].(map[string]any), sources[1].(map[string]any)
if first["fingerprint"] != "fp-b" || first["status"] != "dead" || second["status"] != "healthy" {
t.Errorf("the dead source should lead, got %v then %v", first, second)
}
}
// Every switch keeps its own deadline.
func TestDeadman_TimeoutsArePerSwitch(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
"matcher": "alertname=Edge", "timeout_seconds": 300,
})
resp.Body.Close()
heartbeat(t, s, "fp-watchdog", nil)
postWebhook(t, s, []map[string]any{
amAlert("fp-edge", "Edge", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
}, `{}:{alertname="Edge"}`)
// Ten minutes of silence: past the Edge switch's five, inside Watchdog's hour.
silence(t, s, "fp-watchdog", 10*time.Minute)
silence(t, s, "fp-edge", 10*time.Minute)
got := listSwitches(t, s)
if got[0]["status"] != "healthy" || got[1]["status"] != "dead" {
t.Errorf("want Watchdog healthy and Edge dead, got %v and %v", got[0]["status"], got[1]["status"])
}
}
// Deleting is an owner's, is scoped to the team, and leaves what the switch
// already opened alone.
func TestDeadman_DeleteIsScopedToTheTeam(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
other := newTeam(t, s, "other")
id := int64(listSwitches(t, s)[0]["id"].(float64))
// Another team's owner cannot reach it.
resp := other.call(http.MethodDelete, "/api/teams/"+id64(other.id)+"/deadman/switches/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting another team's switch: expected 404, got %d", resp.StatusCode)
}
if got := len(listSwitches(t, s)); got != 1 {
t.Fatalf("the switch should have survived, %d left", got)
}
resp = s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("deleting: expected 204, got %d", resp.StatusCode)
}
if got := len(listSwitches(t, s)); got != 0 {
t.Errorf("expected no switches, got %d", got)
}
}
// The environment's defaults are handed out once and then belong to the teams.
func TestDeadman_SeedRunsOnce(t *testing.T) {
s := newTS(t)
cfg := api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
t.Fatalf("seed: %v", err)
}
if got := len(listSwitches(t, s)); got != 1 {
t.Fatalf("the first seed should add the default, got %d switches", got)
}
// The owner deletes it; a restart must not put it back.
id := int64(listSwitches(t, s)[0]["id"].(float64))
s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil).Body.Close()
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
t.Fatalf("seed again: %v", err)
}
if got := len(listSwitches(t, s)); got != 0 {
t.Errorf("a second seed resurrected %d switch(es)", got)
}
}
+506
View File
@@ -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))
}
}
+385
View File
@@ -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)
}
}
+58
View File
@@ -2,9 +2,67 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
)
// sqlArgs accumulates query arguments and hands back the placeholder for each.
//
// Postgres numbers its placeholders, so a dynamically assembled WHERE clause has
// to keep its $1, $2, … in step with the order of the values — which SQLite's
// positional `?` did for free. Handing out the placeholder and storing the value
// in one call is what keeps them in step: a filter can be added, removed or
// reordered without renumbering anything by hand.
type sqlArgs struct{ vals []any }
// add stores v and returns the placeholder that refers to it.
func (a *sqlArgs) add(v any) string {
a.vals = append(a.vals, v)
return "$" + strconv.Itoa(len(a.vals))
}
// addList stores every value and returns their placeholders as "$1, $2, …",
// ready to drop into an IN (…) clause. Returns an empty string for no values,
// which no caller should reach: `IN ()` is a syntax error in Postgres as it was
// in SQLite, so callers check for an empty set before building the query.
func (a *sqlArgs) addList(vs []any) string {
parts := make([]string, len(vs))
for i, v := range vs {
parts[i] = a.add(v)
}
return strings.Join(parts, ", ")
}
// all returns the accumulated values, to be passed straight to Query or Exec.
func (a *sqlArgs) all() []any { return a.vals }
// nowEpoch is the SQL expression for "now, as unix seconds", matching how every
// timestamp in this schema is stored. SQLite spelled it unixepoch().
//
// FLOOR, not a bare cast: EXTRACT returns fractional seconds and casting to
// bigint rounds half up, so a row written at .6 of a second would claim a
// timestamp one second in the future — off by one against the time.Now().Unix()
// the Go side stamps, which is what the expiry tests measure.
const nowEpoch = "FLOOR(EXTRACT(EPOCH FROM now()))::bigint"
// isUniqueViolation reports whether err is a broken unique constraint, which
// callers turn into 409 Conflict rather than 500.
//
// Postgres reports it as SQLSTATE 23505 on a typed error; the SQLite driver this
// replaced only put "UNIQUE constraint failed" in the message, which is why the
// check used to be a substring match. Matching the code means a renamed
// constraint or a translated message cannot quietly turn a conflict back into a
// 500.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
}
func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+74 -20
View File
@@ -36,6 +36,10 @@ const (
evUnsnoozed = "unsnoozed"
evResolved = "resolved"
evNote = "note"
// evResolutionNote is the note worth finding again: what fixed it. The
// similar-incidents lookup and the page lead with these; plain notes are
// the working chatter and stay one click away.
evResolutionNote = "resolution_note"
evDeadmanSilent = "deadman_silent"
)
@@ -51,12 +55,20 @@ type querier interface {
}
const incidentSelectFrom = `
SELECT i.id, 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.acknowledged_by, i.acknowledged_at, ack.username,
i.assigned_to, asg.username, i.snoozed_until,
i.resolved_at, i.resolution_source, i.archived_at
FROM incidents i
JOIN teams t ON t.id = i.team_id
LEFT JOIN users ack ON ack.id = i.acknowledged_by
LEFT JOIN users asg ON asg.id = i.assigned_to`
@@ -64,10 +76,11 @@ func scanIncident(s scanner) (models.Incident, error) {
var i models.Incident
var groupLabelsJSON string
var triggeredAt int64
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64
if err := s.Scan(
&i.ID, &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,
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
@@ -82,6 +95,7 @@ func scanIncident(s scanner) (models.Incident, error) {
i.SnoozedUntil = unixPtr(snoozedUntil)
i.ResolvedAt = unixPtr(resolvedAt)
i.ArchivedAt = unixPtr(archivedAt)
i.EscalationDueAt = unixPtr(escalationDue)
return i, nil
}
@@ -95,7 +109,7 @@ func unixPtr(sec *int64) *time.Time {
}
func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, error) {
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = ?", id))
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id))
}
// logEvent appends one entry to an incident's timeline. A nil userID means the
@@ -103,7 +117,7 @@ func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, e
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
_, err := q.ExecContext(ctx, `
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
VALUES ($1, $2, $3, $4, $5, $6)`,
incidentID, evType, userID, alertID, detail, time.Now().Unix())
return err
}
@@ -113,12 +127,17 @@ func todayUTC() string {
return time.Now().UTC().Format("2006-01-02")
}
// currentOnCall returns today's on-call user, or nil when nobody is scheduled.
// A missing schedule entry is not an error — incidents just open unassigned.
func currentOnCall(ctx context.Context, q querier) (*int64, error) {
// currentOnCall returns a team's on-call user for today, or nil when nobody is
// scheduled. A missing schedule entry is not an error — incidents just open
// unassigned.
//
// Per team: each team keeps its own rota, so two teams can have two different
// people on call on the same day, which was the point of scoping the schedule.
func currentOnCall(ctx context.Context, q querier, teamID int64) (*int64, error) {
var userID int64
err := q.QueryRowContext(ctx,
"SELECT user_id FROM schedule_entries WHERE date = ?", todayUTC()).Scan(&userID)
"SELECT user_id FROM schedule_entries WHERE team_id = $1 AND date = $2",
teamID, todayUTC()).Scan(&userID)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -154,10 +173,10 @@ func severityRank(s string) int {
// in the queue while the work is still open.
func refreshSeverity(ctx context.Context, q querier, incidentID int64) error {
rows, err := q.QueryContext(ctx, `
SELECT json_extract(a.labels, '$.'||?)
SELECT a.labels ->> $1
FROM incident_alerts ia
JOIN alerts a ON a.id = ia.alert_id
WHERE ia.incident_id = ?`, severityLabel, incidentID)
WHERE ia.incident_id = $2`, severityLabel, incidentID)
if err != nil {
return err
}
@@ -185,9 +204,9 @@ func refreshSeverity(ctx context.Context, q querier, incidentID int64) error {
// The comparison lives in SQL so an unrelated concurrent update cannot be
// clobbered by a stale read.
_, err = q.ExecContext(ctx, `
UPDATE incidents SET severity = ?
WHERE id = ?
AND (severity IS NULL OR `+severityRankSQL("severity")+` < ?)`,
UPDATE incidents SET severity = $1
WHERE id = $2
AND (severity IS NULL OR `+severityRankSQL("severity")+` < $3)`,
best, incidentID, severityRank(best))
return err
}
@@ -210,9 +229,9 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
res, err := q.ExecContext(ctx, `
UPDATE incidents
SET status = 'resolved',
resolved_at = ?,
resolution_source = ?
WHERE id = ?
resolved_at = $1,
resolution_source = $2
WHERE id = $3
AND resolved_at IS NULL
-- An incident with no members yet is mid-creation, not settled.
AND EXISTS (SELECT 1 FROM incident_alerts ia WHERE ia.incident_id = incidents.id)
@@ -229,6 +248,9 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
if n == 0 {
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 {
return false, err
}
@@ -245,8 +267,8 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
res, err := q.ExecContext(ctx, `
UPDATE incidents
SET status = 'acknowledged', acknowledged_by = ?, acknowledged_at = ?
WHERE id = ? AND resolved_at IS NULL`,
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
WHERE id = $3 AND resolved_at IS NULL`,
userID, time.Now().Unix(), incidentID)
if err != nil {
return false, err
@@ -254,6 +276,10 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
if n, _ := res.RowsAffected(); n == 0 {
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)
}
@@ -266,13 +292,41 @@ func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64,
SELECT i.id
FROM incident_alerts ia
JOIN incidents i ON i.id = ia.incident_id
WHERE ia.alert_id = ? AND i.resolved_at IS NULL`, alertID).Scan(&id)
WHERE ia.alert_id = $1 AND i.resolved_at IS NULL`, alertID).Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
return id, err
}
// volatileLabels say where a problem ran this time, not what the problem is, so
// they stay out of the signature. Migration 008's backfill lists the same set.
var volatileLabels = map[string]bool{
"instance": true, "pod": true, "pod_name": true, "pod_ip": true,
"container": true, "container_name": true, "endpoint": true,
}
// incidentSignature identifies "the same problem" across incidents: the alert
// name plus the stable group labels, sorted. Incidents in one team with equal
// signatures are what the similar-incidents lookup returns. title stands in for
// the name when the payload carried no alertname (groupless and dead man's
// switch incidents).
func incidentSignature(groupLabels map[string]string, title string) string {
name := groupLabels["alertname"]
if name == "" {
name = title
}
rest := make([]string, 0, len(groupLabels))
for k, v := range groupLabels {
if k == "alertname" || volatileLabels[k] {
continue
}
rest = append(rest, k+"="+v)
}
sort.Strings(rest)
return name + "|" + strings.Join(rest, ",")
}
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
// leading with the alert name and appending whatever else the operator grouped
// by. Falls back to the alert's own name when the payload carried no groupLabels.
+93 -51
View File
@@ -3,6 +3,7 @@ package api
import (
"database/sql"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@@ -17,13 +18,21 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
q := r.URL.Query()
where := []string{}
args := []any{}
args := &sqlArgs{}
// The combined queue: every team the caller belongs to, in one list. A
// caller in no team sees an empty queue rather than everybody's.
where = append(where, "i.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
if team := q.Get("team_id"); team != "" {
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
where = append(where, "i.team_id = "+args.add(n))
}
}
// Without an explicit status the queue shows open work, which is what an
// on-call person opens the tool to see.
if status := q.Get("status"); status != "" {
where = append(where, "i.status = ?")
args = append(args, status)
where = append(where, "i.status = "+args.add(status))
} else {
where = append(where, "i.resolved_at IS NULL")
}
@@ -36,33 +45,27 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
// A snooze expires by simply falling into the past; nothing sweeps it.
if q.Get("snoozed") == "true" {
where = append(where, "i.snoozed_until > ?")
args = append(args, time.Now().Unix())
where = append(where, "i.snoozed_until > "+args.add(time.Now().Unix()))
} else {
where = append(where, "(i.snoozed_until IS NULL OR i.snoozed_until <= ?)")
args = append(args, time.Now().Unix())
where = append(where, "(i.snoozed_until IS NULL OR i.snoozed_until <= "+args.add(time.Now().Unix())+")")
}
if severity := q.Get("severity"); severity != "" {
where = append(where, "i.severity = ?")
args = append(args, severity)
where = append(where, "i.severity = "+args.add(severity))
}
if assignee := q.Get("assigned_to"); assignee != "" {
if n, err := strconv.ParseInt(assignee, 10, 64); err == nil {
where = append(where, "i.assigned_to = ?")
args = append(args, n)
where = append(where, "i.assigned_to = "+args.add(n))
}
}
if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
where = append(where, "i.triggered_at >= ?")
args = append(args, t.UTC().Unix())
where = append(where, "i.triggered_at >= "+args.add(t.UTC().Unix()))
}
}
if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
where = append(where, "i.triggered_at < ?")
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
where = append(where, "i.triggered_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
}
}
@@ -77,12 +80,11 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
if q.Get("sort") == "severity" {
order = severityRankSQL("i.severity") + " DESC, i.triggered_at DESC"
}
args = append(args, limit)
rows, err := db.QueryContext(r.Context(),
fmt.Sprintf("%s WHERE %s ORDER BY %s LIMIT ?",
incidentSelectFrom, strings.Join(where, " AND "), order),
args...)
fmt.Sprintf("%s WHERE %s ORDER BY %s LIMIT %s",
incidentSelectFrom, strings.Join(where, " AND "), order, args.add(limit)),
args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -104,7 +106,7 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
func handleGetIncident(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -127,7 +129,7 @@ func handleGetIncident(db *sql.DB) http.HandlerFunc {
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -145,7 +147,7 @@ func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -158,7 +160,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
e.alert_id, e.detail, e.created_at
FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id
WHERE e.incident_id = ?
WHERE e.incident_id = $1
ORDER BY e.created_at ASC, e.id ASC`, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -184,7 +186,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -207,14 +209,14 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
user, _ := userFromContext(r.Context())
if !updateOpenIncident(w, r, db, id,
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
WHERE id = ? AND resolved_at IS NULL`, id) {
WHERE id = $1 AND resolved_at IS NULL`, id) {
return
}
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
@@ -231,28 +233,48 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
// re-send of an alert that never stopped firing. Use snooze for "not now".
func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
user, _ := userFromContext(r.Context())
// The body is optional: clients that predate resolution notes send none.
var req struct {
Resolution string `json:"resolution"`
}
if err := decodeJSON(r, &req); err != nil && err != io.EOF {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Resolution = strings.TrimSpace(req.Resolution)
if !updateOpenIncident(w, r, db, id,
`UPDATE incidents SET status = 'resolved', resolved_at = ?, resolution_source = ?
WHERE id = ? AND resolved_at IS NULL`,
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
WHERE id = $3 AND resolved_at IS NULL`,
time.Now().Unix(), incidentResolutionManual, id) {
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 {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if req.Resolution != "" {
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
respondIncident(w, r, db, id)
}
}
func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -269,13 +291,13 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
}
var exists int
if err := db.QueryRowContext(r.Context(),
"SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
"SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET assigned_to = ? WHERE id = ? AND resolved_at IS NULL",
"UPDATE incidents SET assigned_to = $1 WHERE id = $2 AND resolved_at IS NULL",
req.UserID, id) {
return
}
@@ -293,7 +315,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
// {"duration": "2h"}.
func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -333,7 +355,7 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
user, _ := userFromContext(r.Context())
if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = ? WHERE id = ? AND resolved_at IS NULL",
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
until.Unix(), id) {
return
}
@@ -348,13 +370,13 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
user, _ := userFromContext(r.Context())
if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = NULL WHERE id = ? AND resolved_at IS NULL", id) {
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
return
}
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
@@ -367,12 +389,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE incidents SET archived_at = unixepoch() WHERE id = ?", id)
"UPDATE incidents SET archived_at = "+nowEpoch+" WHERE id = $1", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -387,12 +409,12 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE incidents SET archived_at = NULL WHERE id = ?", id)
"UPDATE incidents SET archived_at = NULL WHERE id = $1", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -409,17 +431,23 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
// single query renders the whole story of an incident in order.
func handleCreateNote(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
var req struct {
Content string `json:"content"`
// Pinned files the note as the resolution note: what fixed it.
Pinned bool `json:"pinned"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
noteType := evNote
if req.Pinned {
noteType = evResolutionNote
}
if req.Content == "" {
respond(w, http.StatusBadRequest, errResp("content is required"))
return
@@ -430,19 +458,20 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
user, _ := userFromContext(r.Context())
now := time.Now()
res, err := db.ExecContext(r.Context(), `
var eventID int64
err := db.QueryRowContext(r.Context(), `
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
VALUES (?, ?, ?, ?, ?)`, id, evNote, user.ID, req.Content, now.Unix())
VALUES ($1, $2, $3, $4, $5)
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
eventID, _ := res.LastInsertId()
respond(w, http.StatusCreated, models.IncidentEvent{
ID: eventID,
IncidentID: id,
Type: evNote,
Type: noteType,
UserID: &user.ID,
Username: &user.Username,
Detail: &req.Content,
@@ -455,7 +484,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
// rest of the timeline is what actually happened, and is not editable.
func handleDeleteNote(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r)
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
@@ -468,8 +497,8 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
user, _ := userFromContext(r.Context())
res, err := db.ExecContext(r.Context(), `
DELETE FROM incident_events
WHERE id = ? AND incident_id = ? AND type = ? AND user_id = ?`,
eventID, id, evNote, user.ID)
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
eventID, id, evNote, evResolutionNote, user.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -486,19 +515,32 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
// Shared handler plumbing
// ---------------------------------------------------------------------------
func incidentIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
// incidentIDParam reads {id} from the path AND confirms the incident belongs to
// a team the caller is in. Both in one place, deliberately: every incident route
// goes through here, so scoping cannot be forgotten by writing a new handler
// that only remembers the first half.
//
// An incident in somebody else's team is reported as not found rather than
// forbidden, because "there is an incident 41 you may not see" is itself
// something only that team should know.
func incidentIDParam(w http.ResponseWriter, r *http.Request, db *sql.DB) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid incident id"))
return 0, false
}
if !incidentExists(w, r, db, id) {
return 0, false
}
return id, true
}
// incidentExists reports whether the incident is one the caller may see at all.
func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) bool {
var exists int
if err := db.QueryRowContext(r.Context(),
"SELECT 1 FROM incidents WHERE id = ?", id).Scan(&exists); err != nil {
"SELECT 1 FROM incidents WHERE id = $1 AND team_id = ANY($2)",
id, callerTeamIDs(r.Context())).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("incident not found"))
return false
}
@@ -538,7 +580,7 @@ func respondIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id int6
func incidentAlerts(r *http.Request, db *sql.DB, id int64) ([]models.Alert, error) {
rows, err := db.QueryContext(r.Context(), alertSelectFrom+`
JOIN incident_alerts m ON m.alert_id = a.id
WHERE m.incident_id = ?
WHERE m.incident_id = $1
ORDER BY a.received_at DESC`, id)
if err != nil {
return nil, err
+7 -105
View File
@@ -4,14 +4,10 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
// amAlert builds one alert of a webhook payload.
@@ -288,7 +284,7 @@ func TestExpiry_CascadesToIncidentResolution(t *testing.T) {
s := newTS(t)
postAlert(t, s, "fp-exp", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'fp-exp'",
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-exp'",
time.Now().Add(-10*time.Hour).Unix())
sweep(t, s, 6*time.Hour)
@@ -429,7 +425,7 @@ func TestIncident_AutoAssignedToCurrentOnCall(t *testing.T) {
s := newTS(t)
today := time.Now().UTC().Format("2006-01-02")
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{today}})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
@@ -611,9 +607,9 @@ func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
})
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
s.exec(t, "UPDATE incidents SET resolved_at = ? WHERE id = 1",
s.exec(t, "UPDATE incidents SET resolved_at = $1 WHERE id = 1",
time.Now().Add(-30*24*time.Hour).Unix())
api.Sweep(context.Background(), s.db, 7*24*time.Hour, 6*time.Hour, s.deadman, s.notify)
api.Sweep(context.Background(), s.db, 7*24*time.Hour, 6*time.Hour, s.notify)
if inc := getIncident(t, s, 1); inc["archived_at"] == nil {
t.Error("expected the sweeper to archive a long-resolved incident")
@@ -654,8 +650,9 @@ func TestStats_Incidents(t *testing.T) {
}
// An empty window is a report of zero, not a failure. SUM over no rows is NULL
// in SQLite, which used to come back as a 500 the moment every incident was
// archived — the state a quiet installation settles into.
// in Postgres as it was in SQLite, and that used to come back as a 500 the
// moment every incident was archived — the state a quiet installation settles
// into.
func TestStats_IncidentsEmptyWindowIsZeroNotAnError(t *testing.T) {
s := newTS(t)
@@ -722,101 +719,6 @@ func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Migration backfill
// ---------------------------------------------------------------------------
// An upgrade must not drop the acknowledgements and comments people already
// have, so 008 is replayed here over a database left at 007.
func TestMigration_BackfillCarriesAckAndComments(t *testing.T) {
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
files, err := filepath.Glob("../db/migrations/*.sql")
if err != nil || len(files) == 0 {
t.Fatalf("find migrations: %v", err)
}
sort.Strings(files)
var incidentsMigration string
for _, f := range files {
if filepath.Base(f) >= "008" {
incidentsMigration = f
break
}
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
if _, err := database.Exec(string(data)); err != nil {
t.Fatalf("apply %s: %v", f, err)
}
}
if incidentsMigration == "" {
t.Fatal("008 migration not found")
}
// A database as it would look on the old schema: an acknowledged firing
// alert with a comment on it.
if _, err := database.Exec(`
INSERT INTO users (id, username, email) VALUES (1, 'admin', 'admin@test.com');
INSERT INTO alerts (id, fingerprint, name, status, labels, annotations,
starts_at, received_at, acknowledged_by, acknowledged_at)
VALUES (1, 'legacy-fp', 'LegacyAlert', 'firing',
'{"severity":"warning"}', '{}', 1000, 1000, 1, 1500);
INSERT INTO alert_comments (alert_id, user_id, content, created_at)
VALUES (1, 1, 'legacy comment', 1600);`); err != nil {
t.Fatalf("seed pre-008 data: %v", err)
}
data, err := os.ReadFile(incidentsMigration)
if err != nil {
t.Fatalf("read 008: %v", err)
}
if _, err := database.Exec(string(data)); err != nil {
t.Fatalf("apply 008: %v", err)
}
var status, groupKey string
var ackBy int64
var severity string
if err := database.QueryRow(
"SELECT status, group_key, acknowledged_by, severity FROM incidents WHERE id = 1",
).Scan(&status, &groupKey, &ackBy, &severity); err != nil {
t.Fatalf("read backfilled incident: %v", err)
}
if status != "acknowledged" {
t.Errorf("expected the ack to carry over as status, got %q", status)
}
if groupKey != "backfill:legacy-fp" {
t.Errorf("unexpected group_key %q", groupKey)
}
if ackBy != 1 {
t.Errorf("expected acknowledged_by 1, got %d", ackBy)
}
if severity != "warning" {
t.Errorf("expected severity carried from labels, got %q", severity)
}
var notes int
if err := database.QueryRow(
"SELECT COUNT(*) FROM incident_events WHERE type = 'note' AND detail = 'legacy comment'",
).Scan(&notes); err != nil {
t.Fatalf("count notes: %v", err)
}
if notes != 1 {
t.Errorf("expected the comment to become a note, got %d", notes)
}
// And the columns that caused the ack-survives-a-re-fire bug are gone.
if _, err := database.Exec("SELECT acknowledged_by FROM alerts"); err == nil {
t.Error("expected alerts.acknowledged_by to be dropped")
}
}
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
+139 -7
View File
@@ -17,6 +17,7 @@ type contextKey string
const (
ctxUser contextKey = "user"
ctxSession contextKey = "session"
ctxTeams contextKey = "teams"
)
// AuthMiddleware accepts either of the two credentials the server issues: an
@@ -66,11 +67,44 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
}
}
// AdminOnly rejects a caller who is not a system administrator. It runs inside
// AuthMiddleware's group, so by the time it sees a request the caller is known.
//
// 403 and not 404: the route exists and the caller is authenticated, they are
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
// is in the README.
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
caller, ok := userFromContext(r.Context())
if !ok || !caller.IsAdmin {
respond(w, http.StatusForbidden, errResp("administrator access required"))
return
}
next.ServeHTTP(w, r)
})
}
// requireSelfOrAdmin guards the endpoints that are self-service for your own
// account and administration for anybody else's: your password, your ntfy
// topic, your API keys. Reports whether the request may proceed, and answers it
// if not.
//
// An API key is not an escalation: it carries exactly the rights of the user it
// belongs to, so minting your own is no more than signing in again.
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
caller, ok := userFromContext(r.Context())
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
respond(w, http.StatusForbidden, errResp("administrator access required"))
return false
}
return true
}
// apiKeyUser resolves an API key to its user and stamps its last use.
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
var keyID, userID int64
err := db.QueryRowContext(ctx,
"SELECT id, user_id FROM api_keys WHERE key_hash = ?", hashToken(token),
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
).Scan(&keyID, &userID)
if err != nil {
return 0, false
@@ -78,7 +112,7 @@ func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
// best-effort; don't fail the request if this update fails
db.ExecContext(ctx,
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
time.Now().Unix(), keyID)
return userID, true
}
@@ -91,7 +125,7 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
var lastSeen int64
err := db.QueryRowContext(ctx, `
SELECT id, user_id, last_seen_at FROM sessions
WHERE token_hash = ? AND expires_at > ?`,
WHERE token_hash = $1 AND expires_at > $2`,
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
if err != nil {
return 0, 0, false
@@ -99,7 +133,7 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx,
"UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?",
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
}
return sessionID, userID, true
@@ -110,15 +144,29 @@ 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) {
var u models.User
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(),
"SELECT id, username, email, created_at FROM users WHERE id = ?", userID,
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
"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 {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
ctx := context.WithValue(r.Context(), ctxUser, u)
// Every scoped query needs the caller's teams, so they are loaded once here
// rather than per handler. One extra round trip per request, against a
// table with one row per membership.
teams, err := callerMemberships(r.Context(), db, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
ctx := context.WithValue(r.Context(), ctxTeams, teams)
ctx = context.WithValue(ctx, ctxUser, u)
if sessionID != 0 {
ctx = context.WithValue(ctx, ctxSession, sessionID)
}
@@ -135,6 +183,90 @@ func userFromContext(ctx context.Context) (models.User, bool) {
return u, ok
}
// membership is the caller's role in one team.
type membership struct {
teamID int64
role string
}
func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) {
rows, err := db.QueryContext(ctx,
"SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []membership
for rows.Next() {
var m membership
if err := rows.Scan(&m.teamID, &m.role); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY`
// filter every list query carries. An admin is NOT implicitly in every team:
// administration is about accounts, not about reading other people's incidents,
// and an admin who needs to see a team's queue can add themselves to it.
func callerTeamIDs(ctx context.Context) []int64 {
ms, _ := ctx.Value(ctxTeams).([]membership)
ids := make([]int64, 0, len(ms))
for _, m := range ms {
ids = append(ids, m.teamID)
}
return ids
}
// callerRole reports the caller's role in one team, and whether they are in it
// at all.
func callerRole(ctx context.Context, teamID int64) (string, bool) {
ms, _ := ctx.Value(ctxTeams).([]membership)
for _, m := range ms {
if m.teamID == teamID {
return m.role, true
}
}
return "", false
}
// requireTeamMember answers the request and reports false unless the caller
// belongs to teamID.
//
// 404, not 403: whether a team exists is itself something only its members
// should learn, and the same reasoning applies to every incident and alert
// under it.
func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool {
if _, ok := callerRole(r.Context(), teamID); !ok {
respond(w, http.StatusNotFound, errResp("not found"))
return false
}
return true
}
// requireTeamOwner is requireTeamMember for the things only an owner may change:
// the schedule, the integrations and who is in the team. A system administrator
// passes without being a member, because somebody has to be able to repair a
// team whose owner has left.
func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool {
role, ok := callerRole(r.Context(), teamID)
if ok && role == models.RoleOwner {
return true
}
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
return true
}
if !ok {
respond(w, http.StatusNotFound, errResp("not found"))
return false
}
respond(w, http.StatusForbidden, errResp("team owner access required"))
return false
}
// sessionFromContext returns the id of the session a request was authenticated
// with, or false for an API-key request.
func sessionFromContext(ctx context.Context) (int64, bool) {
+53 -15
View File
@@ -43,6 +43,10 @@ const (
notifyTriggered = "triggered"
notifyReminder = "reminder"
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
@@ -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.
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
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)
}
@@ -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
// backlog that all lands at once when it comes back.
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
}
now := time.Now()
@@ -151,19 +162,25 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
JOIN incidents i ON i.id = n.incident_id
WHERE n.id = (SELECT MAX(id) FROM notifications WHERE incident_id = n.incident_id)
AND n.sent_at IS NOT NULL
AND n.created_at <= ?
AND n.created_at <= $1
AND 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 <= ?)`,
now.Add(-cfg.RepeatEvery).Unix(), now.Unix())
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
-- 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 {
log.Printf("notifier: find reminders: %v", err)
return
}
// Collected before inserting: the pool is limited to a single connection, so
// an open cursor would block the writes behind it.
// Collected before inserting, rather than written while walking the cursor:
// the inserts below are what this query selects on, and a cursor reading its
// own writes is a hazard whatever the pool size.
var pending []due
for rows.Next() {
var d due
@@ -217,7 +234,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
continue
}
if _, err := db.ExecContext(ctx,
"UPDATE notifications SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?",
"UPDATE notifications SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE id = $2",
time.Now().Unix(), n.id); err != nil {
log.Printf("notifier: mark sent %d: %v", n.id, err)
}
@@ -240,10 +257,10 @@ func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error)
SELECT id, incident_id, user_id, topic, kind, attempts
FROM notifications
WHERE sent_at IS NULL
AND send_after <= ?
AND attempts < ?
AND send_after <= $1
AND attempts < $2
ORDER BY id
LIMIT ?`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
LIMIT $3`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
if err != nil {
return nil, err
}
@@ -269,7 +286,7 @@ func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error)
func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
next := time.Now().Add(retryDelay(n.attempts)).Unix()
if _, err := db.ExecContext(ctx,
"UPDATE notifications SET attempts = attempts + 1, send_after = ?, last_error = ? WHERE id = ?",
"UPDATE notifications SET attempts = attempts + 1, send_after = $1, last_error = $2 WHERE id = $3",
next, cause.Error(), n.id); err != nil {
log.Printf("notifier: mark failed %d: %v", n.id, err)
}
@@ -307,12 +324,22 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err
SELECT COUNT(*)
FROM incident_alerts ia
JOIN alerts a ON a.id = ia.alert_id
WHERE ia.incident_id = ? AND a.status = 'firing'`, n.incidentID).Scan(&firing); err != nil {
WHERE ia.incident_id = $1 AND a.status = 'firing'`, n.incidentID).Scan(&firing); err != nil {
return fmt.Errorf("count firing: %w", err)
}
msg := renderNotification(inc, n, firing, cfg)
// The page that opens an incident carries what fixed it last time, so the
// person woken up starts from that. Best effort: a failed lookup must not
// hold back the page itself.
if n.kind == notifyTriggered {
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
notes := sim[0].ResolutionNotes
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
}
}
// An Acknowledge button needs both a user to attribute the acknowledgement
// to and a URL the phone can reach. Minted per delivery, so every push
// carries its own short-lived token rather than reusing one.
@@ -475,7 +502,7 @@ func enqueueNotification(ctx context.Context, q querier, incidentID int64, userI
now := time.Now().Unix()
_, err := q.ExecContext(ctx, `
INSERT INTO notifications (incident_id, user_id, topic, kind, created_at, send_after)
VALUES (?, ?, ?, ?, ?, ?)`, incidentID, userID, topic, kind, now, now)
VALUES ($1, $2, $3, $4, $5, $6)`, incidentID, userID, topic, kind, now, now)
return err
}
@@ -489,7 +516,7 @@ func notifyTarget(ctx context.Context, q querier, cfg NotifyConfig, onCall *int6
if onCall != nil {
var t *string
err := q.QueryRowContext(ctx,
"SELECT ntfy_topic FROM users WHERE id = ?", *onCall).Scan(&t)
"SELECT ntfy_topic FROM users WHERE id = $1", *onCall).Scan(&t)
if err == nil && t != nil && *t != "" {
return *t, onCall
}
@@ -522,7 +549,7 @@ func enqueueResolved(ctx context.Context, q querier, incidentID int64) error {
var userID *int64
err := q.QueryRowContext(ctx, `
SELECT topic, user_id FROM notifications
WHERE incident_id = ? ORDER BY id DESC LIMIT 1`, incidentID).Scan(&topic, &userID)
WHERE incident_id = $1 ORDER BY id DESC LIMIT 1`, incidentID).Scan(&topic, &userID)
if err == sql.ErrNoRows {
return nil
}
@@ -556,6 +583,17 @@ func plural(n int) string {
return "s"
}
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
// a multi-line note stays one line in a push.
func shorten(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}
// derefString reads a nullable text column as a plain string.
func derefString(s *string) string {
if s == nil {
+3 -3
View File
@@ -28,7 +28,7 @@ func issueAckToken(ctx context.Context, q querier, incidentID, userID int64) (st
now := time.Now()
if _, err := q.ExecContext(ctx, `
INSERT INTO incident_ack_tokens (token_hash, incident_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
VALUES ($1, $2, $3, $4, $5)`,
hash, incidentID, userID, now.Unix(), now.Add(ackTokenTTL).Unix()); err != nil {
return "", err
}
@@ -51,7 +51,7 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
var incidentID, userID int64
err := db.QueryRowContext(r.Context(), `
SELECT incident_id, user_id FROM incident_ack_tokens
WHERE token_hash = ? AND expires_at > ?`,
WHERE token_hash = $1 AND expires_at > $2`,
hash, time.Now().Unix()).Scan(&incidentID, &userID)
if err != nil {
// Unknown and expired get the same answer, so the endpoint cannot be
@@ -87,7 +87,7 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
// fires in practice.
func purgeAckTokens(ctx context.Context, db *sql.DB) {
res, err := db.ExecContext(ctx,
"DELETE FROM incident_ack_tokens WHERE expires_at < ?", time.Now().Unix())
"DELETE FROM incident_ack_tokens WHERE expires_at < $1", time.Now().Unix())
if err != nil {
log.Printf("sweeper: purge ack tokens: %v", err)
return
+30 -9
View File
@@ -69,6 +69,27 @@ func (f *fakeNtfy) messages() []pushed {
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) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -95,7 +116,7 @@ func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
func putOnCall(t *testing.T, s *ts, userID int) {
t.Helper()
today := time.Now().UTC().Format("2006-01-02")
resp := s.req(t, http.MethodPost, "/api/schedule",
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": userID, "dates": []string{today}})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
@@ -125,7 +146,7 @@ func (s *ts) countNotifications(t *testing.T, kind string) int {
query := "SELECT COUNT(*) FROM notifications"
args := []any{}
if kind != "" {
query += " WHERE kind = ?"
query += " WHERE kind = $1"
args = append(args, kind)
}
if err := s.db.QueryRow(query, args...).Scan(&n); err != nil {
@@ -326,7 +347,7 @@ func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
// One pass per attempt, each made due by clearing the backoff the last one set.
for i := 0; i < 10; i++ {
s.sweepNotify(t)
s.exec(t, "UPDATE notifications SET send_after = ? WHERE sent_at IS NULL",
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE sent_at IS NULL",
time.Now().Add(-time.Second).Unix())
}
@@ -415,7 +436,7 @@ func TestNotify_AckRejectsExpiredToken(t *testing.T) {
// Age the token past its TTL. The token's inputs are wall-clock timestamps,
// so this is the same trick the sweeper tests use.
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = ?", time.Now().Add(-time.Minute).Unix())
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
resp, err := http.Post(s.URL+path, "application/json", nil)
if err != nil {
@@ -436,9 +457,9 @@ func TestNotify_SweepPurgesExpiredAckTokens(t *testing.T) {
fireCritical(t, s)
s.sweepNotify(t)
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = ?", time.Now().Add(-time.Minute).Unix())
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
api.Sweep(context.Background(), s.db, 168*time.Hour, 6*time.Hour, s.deadman, s.notify)
api.Sweep(context.Background(), s.db, 168*time.Hour, 6*time.Hour, s.notify)
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM incident_ack_tokens").Scan(&n); err != nil {
@@ -457,7 +478,7 @@ func TestNotify_SweepPurgesExpiredAckTokens(t *testing.T) {
// reminder as due.
func (s *ts) ageNotifications(t *testing.T, by time.Duration) {
t.Helper()
s.exec(t, "UPDATE notifications SET created_at = ? WHERE sent_at IS NOT NULL",
s.exec(t, "UPDATE notifications SET created_at = $1 WHERE sent_at IS NOT NULL",
time.Now().Add(-by).Unix())
}
@@ -693,7 +714,7 @@ func TestNotify_FailedDeliveryRetriesWithBackoff(t *testing.T) {
// Once due and once ntfy recovers, it goes out.
f.failWith(http.StatusOK)
s.exec(t, "UPDATE notifications SET send_after = ? WHERE id = 1", time.Now().Add(-time.Second).Unix())
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE id = 1", time.Now().Add(-time.Second).Unix())
s.sweepNotify(t)
if err := s.db.QueryRow("SELECT sent_at FROM notifications WHERE id = 1").Scan(&sentAt); err != nil {
@@ -715,7 +736,7 @@ func TestNotify_UnsentNotificationBlocksReminders(t *testing.T) {
fireCritical(t, s)
s.sweepNotify(t)
s.exec(t, "UPDATE notifications SET created_at = ?", time.Now().Add(-time.Hour).Unix())
s.exec(t, "UPDATE notifications SET created_at = $1", time.Now().Add(-time.Hour).Unix())
s.sweepNotify(t)
if got := s.countNotifications(t, "reminder"); got != 0 {
+101 -13
View File
@@ -4,16 +4,23 @@ import (
"database/sql"
"net/http"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// NewRouter builds the HTTP surface. notify and deadman are passed through to
// the webhook, the only handler that has to decide where a new incident's page
// goes and which arriving alerts are heartbeats rather than problems. A zero
// notify disables notifications; a zero deadman disables dead man's switches.
func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Handler {
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
// 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
// the database, so nothing about them is wired in here.
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.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -27,12 +34,27 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
// the scoped token in its path rather than an API key, and has to stay
// reachable from outside the cluster for the button to work.
r.Post("/api/bootstrap", handleBootstrap(db))
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman))
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
// 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.
//
// 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))
// Signing up. Both are unauthenticated by necessity: the caller has no
// account yet. The info endpoint says whether the door is open and whether
// an invite link is good, so the form can say so before somebody picks a
// password.
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,
// 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))
// All other /api routes require a valid API key.
@@ -40,14 +62,47 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Use(AuthMiddleware(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
// on-call schedule both need to name people.
r.Get("/api/users", handleListUsers(db))
r.Post("/api/users", handleCreateUser(db))
r.Delete("/api/users/{id}", handleDeleteUser(db))
// Your own account, or anybody's if you are an admin. The handlers call
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
// which rule applies depends on the {id} in the path.
r.Get("/api/users/{id}/teams", handleUserTeams(db))
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
r.Put("/api/users/{id}/password", handleSetPassword(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
// Administration: who exists, and who is an administrator. Until #3
// these were open to any authenticated caller, which meant every user
// could delete every other one.
r.Group(func(r chi.Router) {
r.Use(AdminOnly)
r.Post("/api/users", handleCreateUser(db))
r.Delete("/api/users/{id}", handleDeleteUser(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
// queue. Everything a person does happens on the incident instead.
r.Get("/api/alerts", handleListAlerts(db))
@@ -57,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
@@ -68,10 +124,42 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
r.Post("/api/schedule", handleCreateSchedule(db))
r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id}
r.Get("/api/schedule", handleListSchedule(db))
r.Delete("/api/schedule/{id}", handleDeleteSchedule(db))
// Teams. A user sees the teams they belong to; an owner configures one.
r.Get("/api/teams", handleListTeams(db))
r.Post("/api/teams", handleCreateTeam(db))
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(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,
// and how long a silence has to last before somebody is paged.
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
// Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
// The rota is per team. /api/schedule/current is the exception: it
// answers across every team the caller is in, which is what somebody on
// two rotas wants to see.
r.Get("/api/schedule/current", handleCurrentSchedule(db))
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db))
r.Get("/api/stats/incidents", handleStatsIncidents(db))
r.Get("/api/stats/alerts", handleStatsAlerts(db))
+75 -34
View File
@@ -12,8 +12,18 @@ import (
"github.com/go-chi/chi/v5"
)
// The schedule is per team: each team keeps its own rota, so two teams can have
// two different people on call on the same day. Editing it is an owner's job,
// like the rest of a team's configuration; reading it is any member's.
func handleCreateSchedule(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 {
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
@@ -43,10 +53,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
}
}
// Verify the user exists.
// The person taking the shift has to be in the team: paging somebody
// who cannot open the incident is worse than paging nobody.
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user not found"))
if err := db.QueryRowContext(r.Context(),
"SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, req.UserID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user is not a member of this team"))
return
}
@@ -64,14 +77,16 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
for _, d := range req.Dates {
if req.Replace {
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM schedule_entries WHERE date = ?", d); err != nil {
"DELETE FROM schedule_entries WHERE team_id = $1 AND date = $2",
teamID, d); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO schedule_entries (user_id, date) VALUES (?, ?)", req.UserID, d); err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
"INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)",
teamID, req.UserID, d); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict,
errResp("date already assigned: "+d+" (pass replace to take it)"))
return
@@ -90,7 +105,7 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
for _, d := range req.Dates {
dateSet[d] = true
}
all, err := scheduleRange(r.Context(), db, req.Dates[0], req.Dates[len(req.Dates)-1])
all, err := scheduleRange(r.Context(), db, teamID, req.Dates[0], req.Dates[len(req.Dates)-1])
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -107,6 +122,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
func handleListSchedule(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
}
q := r.URL.Query()
from, to := q.Get("from"), q.Get("to")
@@ -123,7 +145,7 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
}
}
entries, err := scheduleRange(r.Context(), db, from, to)
entries, err := scheduleRange(r.Context(), db, teamID, from, to)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -134,12 +156,20 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
func handleDeleteSchedule(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, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
return
}
res, err := db.ExecContext(r.Context(), "DELETE FROM schedule_entries WHERE id = ?", id)
res, err := db.ExecContext(r.Context(),
"DELETE FROM schedule_entries WHERE id = $1 AND team_id = $2", id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -152,55 +182,66 @@ func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
}
}
// handleCurrentSchedule answers "who is on call right now" for every team the
// caller belongs to — one entry per team, so somebody on two rotas sees both.
// A team with nobody scheduled today simply does not appear.
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
today := time.Now().UTC().Format("2006-01-02")
var e models.ScheduleEntry
var ts int64
err := db.QueryRowContext(r.Context(), `
SELECT s.id, s.user_id, u.username, s.date, s.created_at
rows, err := db.QueryContext(r.Context(), `
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
FROM schedule_entries s
JOIN users u ON u.id = s.user_id
WHERE s.date = ?`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts)
if err == sql.ErrNoRows {
respond(w, http.StatusNotFound, errResp("no one is on call today"))
return
}
JOIN teams t ON t.id = s.team_id
WHERE s.date = $1 AND s.team_id = ANY($2)
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
e.CreatedAt = time.Unix(ts, 0).UTC()
respond(w, http.StatusOK, e)
defer rows.Close()
entries := []models.ScheduleEntry{}
for rows.Next() {
var e models.ScheduleEntry
var ts int64
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
e.CreatedAt = time.Unix(ts, 0).UTC()
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, entries)
}
}
// scheduleRange returns schedule entries ordered by date.
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.ScheduleEntry, error) {
where := []string{}
args := []any{}
func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
args := &sqlArgs{}
where := []string{"s.team_id = " + args.add(teamID)}
if from != "" {
where = append(where, "s.date >= ?")
args = append(args, from)
where = append(where, "s.date >= "+args.add(from))
}
if to != "" {
where = append(where, "s.date <= ?")
args = append(args, to)
where = append(where, "s.date <= "+args.add(to))
}
clause := "1=1"
if len(where) > 0 {
clause = strings.Join(where, " AND ")
}
clause := strings.Join(where, " AND ")
rows, err := db.QueryContext(ctx, `
SELECT s.id, s.user_id, u.username, s.date, s.created_at
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
FROM schedule_entries s
JOIN users u ON u.id = s.user_id
JOIN teams t ON t.id = s.team_id
WHERE `+clause+`
ORDER BY s.date ASC`, args...)
ORDER BY s.date ASC`, args.all()...)
if err != nil {
return nil, err
}
@@ -210,7 +251,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S
for rows.Next() {
var e models.ScheduleEntry
var ts int64
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
return nil, err
}
e.CreatedAt = time.Unix(ts, 0).UTC()
+471
View File
@@ -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)
}
}
+275
View File
@@ -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)
}
}
+489
View File
@@ -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)
}
}
+270
View File
@@ -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)
}
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"context"
"database/sql"
"net/http"
"strconv"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
const (
similarDefaultLimit = 5
similarMaxLimit = 20
)
// handleIncidentSimilar lists earlier, resolved incidents in the same team with
// the same signature that someone left notes on, incidents with a resolution
// note first. This is the "have we seen this before" answer for a responder
// looking at a fresh incident; the plain notes are one timeline fetch away.
func handleIncidentSimilar(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
limit := similarDefaultLimit
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
respond(w, http.StatusBadRequest, errResp("invalid limit"))
return
}
limit = min(n, similarMaxLimit)
}
out, err := similarIncidents(r.Context(), db, id, limit)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) {
rows, err := q.QueryContext(ctx, `
SELECT o.id, o.title, o.triggered_at, o.resolved_at,
(SELECT COUNT(*) FROM incident_events e
WHERE e.incident_id = o.id AND e.type = $3)
FROM incidents i
JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature
WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL
AND EXISTS (SELECT 1 FROM incident_events e
WHERE e.incident_id = o.id AND e.type IN ($3, $4))
ORDER BY EXISTS (SELECT 1 FROM incident_events e
WHERE e.incident_id = o.id AND e.type = $4) DESC,
o.triggered_at DESC
LIMIT $2`, id, limit, evNote, evResolutionNote)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.SimilarIncident{}
ids := []int64{}
for rows.Next() {
var s models.SimilarIncident
var triggered, resolved int64
if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil {
return nil, err
}
s.TriggeredAt = time.Unix(triggered, 0).UTC()
s.ResolvedAt = time.Unix(resolved, 0).UTC()
s.ResolutionNotes = []models.IncidentEvent{}
out = append(out, s)
ids = append(ids, s.ID)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return out, nil
}
nrows, err := q.QueryContext(ctx, `
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at
FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id
WHERE e.incident_id = ANY($1) AND e.type = $2
ORDER BY e.created_at, e.id`, ids, evResolutionNote)
if err != nil {
return nil, err
}
defer nrows.Close()
byID := make(map[int64]*models.SimilarIncident, len(out))
for i := range out {
byID[out[i].ID] = &out[i]
}
for nrows.Next() {
var e models.IncidentEvent
var ts int64
if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil {
return nil, err
}
e.CreatedAt = time.Unix(ts, 0).UTC()
s := byID[e.IncidentID]
s.ResolutionNotes = append(s.ResolutionNotes, e)
}
return out, nrows.Err()
}
+99
View File
@@ -0,0 +1,99 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"testing"
)
// postGrouped posts a firing webhook whose group labels are exactly the given
// map, unlike postWebhook, which only ever groups by alertname.
func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) {
t.Helper()
labels := map[string]string{}
for k, v := range groupLabels {
labels[k] = v
}
payload := map[string]any{
"version": "4", "status": "firing",
"groupKey": fingerprint,
"groupLabels": groupLabels,
"alerts": []map[string]any{
amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels),
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post webhook: %v", err)
}
resp.Body.Close()
}
func similar(t *testing.T, s *ts, id int) []map[string]any {
t.Helper()
var out []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out)
return out
}
// Same alert on another instance is the same problem; a resolution note left on
// the first one is what the second one should be shown.
func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) {
s := newTS(t)
postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z",
map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"})
s.req(t, http.MethodPost, "/api/incidents/1/resolve",
map[string]string{"resolution": "rotated the logs"}).Body.Close()
postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z",
map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"})
got := similar(t, s, 2)
if len(got) != 1 || int(got[0]["id"].(float64)) != 1 {
t.Fatalf("expected incident 1 as the only similar one, got %v", got)
}
notes := got[0]["resolution_notes"].([]any)
if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" {
t.Fatalf("expected the resolution note, got %v", notes)
}
}
// A different stable label (job) is a different problem, and an incident nobody
// wrote a note on has nothing to show.
func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) {
s := newTS(t)
postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z",
map[string]string{"alertname": "DiskFull", "job": "node"})
s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close()
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z",
map[string]string{"alertname": "DiskFull", "job": "db"})
s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close()
postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z",
map[string]string{"alertname": "DiskFull", "job": "db"})
// Incident 3 matches 2 by signature, but 2 has no notes.
if got := similar(t, s, 3); len(got) != 0 {
t.Fatalf("expected nothing similar to incident 3, got %v", got)
}
}
// An open incident is not "earlier experience" yet, and the incident itself is
// never its own match.
func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) {
s := newTS(t)
postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"})
s.req(t, http.MethodPost, "/api/incidents/1/notes",
map[string]any{"content": "still open", "pinned": true}).Body.Close()
postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"})
if got := similar(t, s, 2); len(got) != 0 {
t.Fatalf("expected an open incident not to be listed, got %v", got)
}
}
+23 -21
View File
@@ -11,7 +11,7 @@ import (
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
// nothing is 0 — without it an empty window is a 500 rather than a
@@ -21,7 +21,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
FROM alerts WHERE %s`, where), args...,
FROM alerts WHERE %s`, where), args.all()...,
).Scan(&total, &firing, &resolved)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -37,7 +37,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
func handleStatsTop(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
limit := 10
if l := r.URL.Query().Get("limit"); l != "" {
@@ -47,15 +47,13 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
limit = n
}
}
args = append(args, limit)
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT name, COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY name
ORDER BY cnt DESC
LIMIT ?`, where), args...)
LIMIT %s`, where, args.add(limit)), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -81,15 +79,15 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT CAST(strftime('%%H', datetime(received_at, 'unixepoch')) AS INTEGER) AS hr,
SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY hr
ORDER BY hr ASC`, where), args...)
ORDER BY hr ASC`, where), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -121,16 +119,17 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
// SQLite strftime('%w') → 0=Sunday … 6=Saturday
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering
// SQLite's strftime('%w') returned, so the frontend needs no change.
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT CAST(strftime('%%w', datetime(received_at, 'unixepoch')) AS INTEGER) AS dow,
SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY dow
ORDER BY dow ASC`, where), args...)
ORDER BY dow ASC`, where), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -168,7 +167,7 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
// mutated in place and carry no acknowledgement or closure time.
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at")
where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context()))
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
// The averages are not: mtta and mttr stay null on purpose, since zero
@@ -184,7 +183,7 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
THEN acknowledged_at - triggered_at END),
AVG(CASE WHEN resolved_at IS NOT NULL
THEN resolved_at - triggered_at END)
FROM incidents WHERE %s`, where), args...,
FROM incidents WHERE %s`, where), args.all()...,
).Scan(&total, &triggered, &acknowledged, &resolved, &mtta, &mttr)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -207,18 +206,21 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
// params, filtering on timeCol. Archived rows are always excluded, matching the
// default list views.
func statsFilter(q url.Values, timeCol string) (where string, args []any) {
clauses := []string{"archived_at IS NULL"}
//
// teamIDs scopes every figure to the caller's own teams: a report that counted
// other teams' incidents would leak their volume and their names through the
// top-alerts list, and would not be a number about the reader's work anyway.
func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) {
args = &sqlArgs{}
clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"}
if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
clauses = append(clauses, timeCol+" >= ?")
args = append(args, t.UTC().Unix())
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
}
}
if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
clauses = append(clauses, timeCol+" < ?")
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
clauses = append(clauses, timeCol+" < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
}
}
return strings.Join(clauses, " AND "), args
+681
View File
@@ -0,0 +1,681 @@
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"
)
// handleListTeams lists the caller's own teams, each with their role in it. An
// administrator listing every team goes through the admin endpoint instead:
// this one answers "what am I part of", which is what the UI's team filter and
// the combined queue are built from.
func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
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`, caller.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)
}
}
// handleUserTeams lists one user's teams, for the admin page's per-user view:
// "what is this person in", which /api/teams cannot answer because it is always
// about the caller.
//
// Self or admin, matching the other per-user endpoints. It says which teams
// somebody belongs to and in what role — not anything those teams own, so it
// stays on the accounts side of the line the administrator flag draws.
func handleUserTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
// A user with no teams and a user who does not exist both list nothing,
// so the existence check is what tells them apart.
var exists bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !exists {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
ORDER BY t.name`, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
teams := []models.Team{}
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
teams = append(teams, t)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, teams)
}
}
// handleCreateTeam creates a team and makes its creator the first owner. A team
// with no owner would need an administrator to repair before anybody could use
// it, so the two happen in one transaction.
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
caller, _ := userFromContext(r.Context())
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var team models.Team
var created int64
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at",
req.Name).Scan(&team.ID, &team.Name, &created); 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 _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
team.CreatedAt = time.Unix(created, 0).UTC()
team.Role = models.RoleOwner
respond(w, http.StatusCreated, team)
}
}
// handleDeleteTeam removes a team and, by cascade, its incidents, alerts,
// schedule and integrations.
//
// Refused while the team still has open incidents: deleting a team is tidying
// up, and tidying up should never be how an unacknowledged page disappears.
// Resolve or archive them first, deliberately.
func handleDeleteTeam(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 open int
if err := db.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID).
Scan(&open); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if open > 0 {
respond(w, http.StatusConflict, errResp("team still has open incidents"))
return
}
res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", 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)
}
}
// handleListTeamMembers names everybody in a team. Visible to any member: you
// can see who else is on the rota you are on.
func handleListTeamMembers(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
}
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
}
respond(w, http.StatusOK, members)
}
}
// handleAddTeamMember adds a user to a team, or changes the role of somebody
// already in it.
func handleAddTeamMember(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 {
UserID int64 `json:"user_id"`
Role string `json:"role"`
}
if err := decodeJSON(r, &req); err != nil || req.UserID == 0 {
respond(w, http.StatusBadRequest, errResp("user_id is required"))
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
}
_, err := db.ExecContext(r.Context(), `
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
teamID, req.UserID, req.Role)
if err != nil {
// The only foreign key that can fail here is the user: the team was
// resolved from the caller's own membership.
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleRemoveTeamMember takes a user out of a team.
//
// A team must keep an owner, for the same reason the install must keep an
// administrator: otherwise nobody can configure it, and repairing that needs
// somebody with more access than the team has.
func handleRemoveTeamMember(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
}
userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if last {
respond(w, http.StatusConflict, errResp("cannot remove the last owner of a team"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("not a member of this team"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var last bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM team_members
WHERE team_id = $1 AND user_id = $2 AND role = 'owner')
AND NOT EXISTS (SELECT 1 FROM team_members
WHERE team_id = $1 AND user_id <> $2 AND role = 'owner')`,
teamID, userID).Scan(&last)
return last, err
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
// handleListIntegrations lists a team's integrations. Never the keys: those
// exist in plaintext only in the response that created them.
func handleListIntegrations(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
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, kind, name, created_at, last_used_at
FROM integrations
WHERE team_id = $1
ORDER BY id`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
integrations := []models.Integration{}
for rows.Next() {
var i models.Integration
var created int64
var lastUsed *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.LastUsedAt = unixPtr(lastUsed)
integrations = append(integrations, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, integrations)
}
}
// handleCreateIntegration mints an integration key. The key is returned once,
// in this response, and only its hash is kept — the same handling as an API key
// or an acknowledgement token.
func handleCreateIntegration(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 {
Name string `json:"name"`
Kind string `json:"kind"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.Kind == "" {
req.Kind = models.IntegrationAlertmanager
}
if req.Kind != models.IntegrationAlertmanager {
respond(w, http.StatusBadRequest, errResp("unsupported integration kind"))
return
}
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var i models.Integration
var created int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO integrations (team_id, kind, name, key_hash)
VALUES ($1, $2, $3, $4)
RETURNING id, team_id, kind, name, created_at`,
teamID, req.Kind, req.Name, hash).
Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.Key = raw
i.URL = strings.TrimSuffix(publicURL, "/") + integrationPath(raw, i.Kind)
respond(w, http.StatusCreated, i)
}
}
func handleDeleteIntegration(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, "integrationID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM integrations WHERE id = $1 AND team_id = $2", 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)
}
}
// integrationPath is where a sender of this kind posts. Built in one place so
// the URL handed out at creation and the route the router registers cannot
// drift apart.
func integrationPath(key, kind string) string {
return "/api/integrations/" + key + "/" + kind
}
// teamIDForKey resolves an integration key to its team, and stamps the key's
// last use. An unknown key is not an error worth distinguishing: the caller is
// told nothing beyond "no".
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
var teamID int64
err := db.QueryRowContext(ctx,
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
if errors.Is(err, sql.ErrNoRows) {
return 0, errUnknownIntegration
}
if err != nil {
return 0, err
}
// Best effort, like an API key's: a failed stamp must not reject an alert.
db.ExecContext(ctx, //nolint:errcheck
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
time.Now().Unix(), hashToken(key))
return teamID, nil
}
var errUnknownIntegration = errors.New("unknown integration key")
// teamParam reads {teamID} from the path.
func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "teamID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid team id"))
return 0, false
}
return id, true
}
// defaultTeamID is the oldest team, which on an upgraded install is the
// "Default" team every pre-teams row was moved into and on a fresh one is the
// 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) {
var id int64
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
return id, err
}
// ---------------------------------------------------------------------------
// A team's dead man's switches
// ---------------------------------------------------------------------------
// deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
// because that is what the column holds and what arithmetic is done on; a client
// renders it.
type deadmanSwitchRequest struct {
Name string `json:"name"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
// deadmanSeverities are the severities an incident can open at.
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
// are doing. A team with none gets an empty list, which is a configuration and
// not an absence: answering 404 would make "off" indistinguishable from "this
// server does not do this".
func handleListTeamDeadman(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
}
set, err := deadmanSetForTeam(r.Context(), db, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
// handleCreateTeamDeadman adds one switch.
//
// Validated by parsing: a matcher with no alertname is rejected rather than
// stored, because a switch that silently watches nothing is the failure this
// feature exists to prevent.
func handleCreateTeamDeadman(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 deadmanSwitchRequest
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Matcher = strings.TrimSpace(req.Matcher)
req.Name = strings.TrimSpace(req.Name)
if req.Severity == "" {
req.Severity = "critical"
}
if !deadmanSeverities[req.Severity] {
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
return
}
if req.TimeoutSeconds <= 0 {
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
return
}
if strings.Contains(req.Matcher, ";") {
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
return
}
m, err := parseDeadmanMatcher(req.Matcher)
if err != nil {
respond(w, http.StatusBadRequest, errResp(
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
return
}
if req.Name == "" {
req.Name = m.config()
}
if len(req.Name) > 100 {
respond(w, http.StatusBadRequest, errResp("name is too long"))
return
}
var id int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, deadmanSwitchStatus{
ID: id, Name: req.Name, Matcher: m.config(),
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
Status: switchDormant, Sources: []deadmanSource{},
})
}
}
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
// open until somebody resolves it: deleting the switch says "stop watching", not
// "the problem is gone".
func handleDeleteTeamDeadman(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
}
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("switch not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+350
View File
@@ -0,0 +1,350 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
)
// The whole point of #4: two teams sharing one server must not see each other's
// work. These tests build two of them and check the boundary from both sides.
type teamFixture struct {
id int64
key string // integration key: how alerts get in
call func(method, path string, body any) *http.Response
}
// newTeam creates a team with its own member, integration key and API key. The
// admin does the creating, as an install's first user would.
func newTeam(t *testing.T, s *ts, name string) teamFixture {
t.Helper()
var team struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": name}), &team)
var integration struct {
Key string `json:"key"`
URL string `json:"url"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/integrations",
map[string]string{"name": name + " alertmanager"}), &integration)
if integration.Key == "" {
t.Fatalf("%s: integration key was not returned", name)
}
// A member of this team and no other.
var user struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": name + "-user", "email": name + "@test.com"}), &user)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/members",
map[string]any{"user_id": user.ID, "role": "owner"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("%s: add member: %d", name, resp.StatusCode)
}
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
return teamFixture{
id: team.ID,
key: integration.Key,
call: func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
},
}
}
// postToIntegration sends one firing alert on a team's integration key, the way
// a real Alertmanager receiver would.
func postToIntegration(t *testing.T, s *ts, key, fingerprint, name string) {
t.Helper()
payload := map[string]any{
"version": "4",
"status": "firing",
"groupKey": "{}:{alertname=\"" + name + "\"}",
"groupLabels": map[string]string{"alertname": name},
"alerts": []map[string]any{
amAlert(fingerprint, name, "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/integrations/"+key+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post alert: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("post alert: %d", resp.StatusCode)
}
}
func list(t *testing.T, resp *http.Response) []map[string]any {
t.Helper()
var out []map[string]any
decode(t, resp, &out)
return out
}
// An alert posted on one team's key opens an incident in that team and nowhere
// else, and neither team can read the other's queue.
func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
postToIntegration(t, s, blue.key, "fp-blue", "BlueDiskFull")
redIncidents := list(t, red.call(http.MethodGet, "/api/incidents", nil))
if len(redIncidents) != 1 {
t.Fatalf("red should see exactly its own incident, saw %d", len(redIncidents))
}
if title := redIncidents[0]["title"]; title != "RedDiskFull" {
t.Errorf("red saw %v", title)
}
if teamID := int64(redIncidents[0]["team_id"].(float64)); teamID != red.id {
t.Errorf("red's incident belongs to team %d, want %d", teamID, red.id)
}
blueIncidents := list(t, blue.call(http.MethodGet, "/api/incidents", nil))
if len(blueIncidents) != 1 || blueIncidents[0]["title"] != "BlueDiskFull" {
t.Fatalf("blue should see exactly its own incident, saw %v", blueIncidents)
}
// Reading the other team's incident by id is not found rather than
// forbidden: its existence is the other team's business.
otherID := int64(blueIncidents[0]["id"].(float64))
for _, path := range []string{
"/api/incidents/" + id64(otherID),
"/api/incidents/" + id64(otherID) + "/alerts",
"/api/incidents/" + id64(otherID) + "/timeline",
} {
resp := red.call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("red reading %s: expected 404, got %d", path, resp.StatusCode)
}
}
// And cannot act on it either.
for _, path := range []string{"/acknowledge", "/resolve", "/archive"} {
resp := red.call(http.MethodPost, "/api/incidents/"+id64(otherID)+path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("red posting %s: expected 404, got %d", path, resp.StatusCode)
}
}
}
// Alerts, the raw signal record, are scoped the same way.
func TestTeams_AlertsAndStatsAreScoped(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
postToIntegration(t, s, blue.key, "fp-blue-1", "BlueDiskFull")
postToIntegration(t, s, blue.key, "fp-blue-2", "BlueMemory")
if alerts := list(t, red.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 1 {
t.Errorf("red should see 1 alert, saw %d", len(alerts))
}
if alerts := list(t, blue.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 2 {
t.Errorf("blue should see 2 alerts, saw %d", len(alerts))
}
// Statistics count your own work only — otherwise a team's volume, and the
// names of its alerts, leak through the totals.
var stats map[string]any
decode(t, red.call(http.MethodGet, "/api/stats/alerts", nil), &stats)
if total := stats["total"].(float64); total != 1 {
t.Errorf("red's alert stats counted %v alerts, want 1", total)
}
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
for _, row := range top {
if name := row["name"].(string); name != "RedDiskFull" {
t.Errorf("red's top alerts named %q, which is not theirs", name)
}
}
}
// The same fingerprint, the same groupKey and the same date are all legitimate
// in two teams at once: two clusters running the same rules, two rotas.
func TestTeams_SameFingerprintInTwoTeams(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-shared", "DiskFull")
postToIntegration(t, s, blue.key, "fp-shared", "DiskFull")
for _, team := range []struct {
name string
f teamFixture
}{{"red", red}, {"blue", blue}} {
incidents := list(t, team.f.call(http.MethodGet, "/api/incidents", nil))
if len(incidents) != 1 {
t.Errorf("%s: expected its own incident for the shared fingerprint, saw %d",
team.name, len(incidents))
}
}
// And both rotas can name somebody for the same day.
for _, team := range []struct {
name string
f teamFixture
}{{"red", red}, {"blue", blue}} {
var members []map[string]any
decode(t, team.f.call(http.MethodGet, "/api/teams/"+id64(team.f.id)+"/members", nil), &members)
userID := int64(members[0]["user_id"].(float64))
resp := team.f.call(http.MethodPost, "/api/teams/"+id64(team.f.id)+"/schedule",
map[string]any{"user_id": userID, "dates": []string{"2026-10-01"}})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("%s: taking 2026-10-01 returned %d", team.name, resp.StatusCode)
}
}
}
// An unknown key delivers nothing, and says so rather than accepting silently.
func TestTeams_UnknownIntegrationKeyIsRejected(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
resp, err := http.Post(s.URL+"/api/integrations/not-a-real-key/alertmanager",
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401 for an unknown key, got %d", resp.StatusCode)
}
if incidents := list(t, team.call(http.MethodGet, "/api/incidents", nil)); len(incidents) != 0 {
t.Errorf("a rejected payload opened %d incident(s)", len(incidents))
}
}
// Team configuration is an owner's job; working incidents is a member's.
func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
// A plain member of the same team.
var user struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
call := func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
base := "/api/teams/" + id64(team.id)
for _, c := range []struct {
name string
method string
path string
body any
}{
{"mint an integration key", http.MethodPost, base + "/integrations",
map[string]string{"name": "mine"}},
{"take a shift", http.MethodPost, base + "/schedule",
map[string]any{"user_id": user.ID, "dates": []string{"2026-11-01"}}},
{"add a member", http.MethodPost, base + "/members",
map[string]any{"user_id": 1}},
{"delete the team", http.MethodDelete, base, nil},
} {
resp := call(c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
}
}
// But they can read what the team is doing.
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
resp := call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("reading %s: expected 200, got %d", path, resp.StatusCode)
}
}
}
// A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
base := "/api/teams/" + id64(red.id)
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
resp := blue.call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("blue reading %s: expected 404, got %d", path, resp.StatusCode)
}
}
// /api/teams lists your own, never the install's.
teams := list(t, blue.call(http.MethodGet, "/api/teams", nil))
if len(teams) != 1 || teams[0]["name"] != "blue" {
t.Errorf("blue's team list: %v", teams)
}
}
+119
View File
@@ -0,0 +1,119 @@
package api_test
import (
"database/sql"
"fmt"
"net/url"
"os"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
// Tests run against a real Postgres, because the server does. SQLite's
// ":memory:" gave every test a private database for free; Postgres has no
// equivalent, so isolation is bought with a schema per test.
//
// A schema rather than a database: CREATE DATABASE copies a template on disk and
// costs a hundred milliseconds or so each time, while CREATE SCHEMA plus the one
// baseline migration is a few, and the suite runs a few hundred of them. Each
// test's pool is pinned to its own schema through search_path, so two tests
// cannot see each other's rows even though they share a server.
//
// TERDUT_TEST_DSN must point at a database the test role may create schemas in:
//
// postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable
//
// `make test-db` starts one locally; ci.yaml runs one as a service container.
// An unset DSN fails rather than skips, deliberately — a suite that quietly
// tests nothing is worse than one that does not run.
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
// a path segment. Every test that does not say otherwise works inside it.
const defaultTeam = "1"
var schemaSeq int
// newTestDB returns a migrated database private to this test, and drops it
// afterwards.
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv(testDSNEnv)
if dsn == "" {
t.Fatalf("%s is not set: these tests need Postgres.\n"+
"Run `make test-db` for a local one, then\n"+
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
testDSNEnv, testDSNEnv)
}
schemaSeq++
schema := fmt.Sprintf("test_%d_%d", os.Getpid(), schemaSeq)
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("connect to %s: %v", testDSNEnv, err)
}
defer admin.Close()
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatalf("create schema %s: %v", schema, err)
}
database, err := db.Open(withSearchPath(dsn, schema))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() {
database.Close()
cleanup, err := sql.Open("pgx", dsn)
if err != nil {
return
}
defer cleanup.Close()
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
t.Logf("drop schema %s: %v", schema, err)
}
})
return database
}
// withSearchPath pins a DSN to one schema, so every connection the pool opens
// lands there and nothing has to qualify a table name.
//
// Handles both DSN spellings: a postgres:// URL, and libpq's keyword/value form.
func withSearchPath(dsn, schema string) string {
opt := "-csearch_path=" + schema
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
u, err := url.Parse(dsn)
if err == nil {
q := u.Query()
q.Set("options", opt)
u.RawQuery = q.Encode()
return u.String()
}
}
return dsn + " options='" + opt + "'"
}
+129 -26
View File
@@ -56,27 +56,36 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
return
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
req.Username, req.Email, passwordHash)
if err != nil {
var userID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
userID, _ := res.LastInsertId()
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyRes, err := db.ExecContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, "bootstrap")
if err != nil {
var keyID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
userID, hash, "bootstrap").Scan(&keyID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyID, _ := keyRes.LastInsertId()
// The default team exists from migration 003, on a fresh install too.
// Without a membership the first user signs in to a working server with
// no queue, no schedule and nowhere for an integration to hang off.
if teamID, err := defaultTeamID(r.Context(), db); err == nil {
db.ExecContext(r.Context(), //nolint:errcheck
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+
"ON CONFLICT (team_id, user_id) DO NOTHING",
teamID, userID, models.RoleOwner)
}
user, _ := fetchUser(r.Context(), db, userID)
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
@@ -87,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic 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 {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -98,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var u models.User
var ts int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic); 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"))
return
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
users = append(users, u)
}
respond(w, http.StatusOK, users)
@@ -124,17 +135,17 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
return
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
var id int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
req.Username, req.Email).Scan(&id); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
id, _ := res.LastInsertId()
user, _ := fetchUser(r.Context(), db, id)
respond(w, http.StatusCreated, user)
}
@@ -151,6 +162,9 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
NtfyTopic string `json:"ntfy_topic"`
}
@@ -165,7 +179,7 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET ntfy_topic = ? WHERE id = ?", topic, id)
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -191,7 +205,22 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = ?", id)
// Deleting yourself is how an install ends up with no administrator at
// all, and it is never what somebody meant to do.
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
return
}
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if last {
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
return
}
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -212,6 +241,9 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, userID) {
return
}
var req struct {
Name string `json:"name"`
@@ -226,7 +258,7 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
}
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", userID).Scan(&exists); err != nil {
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
@@ -236,13 +268,13 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, req.Name)
if err != nil {
var keyID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
userID, hash, req.Name).Scan(&keyID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyID, _ := res.LastInsertId()
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
respond(w, http.StatusCreated, key)
}
@@ -255,6 +287,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, userID) {
return
}
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid key id"))
@@ -262,7 +297,7 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM api_keys WHERE id = ? AND user_id = ?", keyID, userID)
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -292,12 +327,80 @@ func randomToken() (raw, hash string, err error) {
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic)
"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, &disabled)
if err != nil {
return u, err
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
return u, nil
}
// handleSetAdmin grants or revokes the system administrator flag.
//
// Revoking is guarded twice: an install must keep at least one administrator,
// and you cannot demote yourself. The first stops the flag being lost
// altogether; the second stops the likelier accident, where the only admin
// clears their own flag while tidying up and locks the door behind them.
func handleSetAdmin(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 {
IsAdmin *bool `json:"is_admin"`
}
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
return
}
if !*req.IsAdmin {
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
return
}
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if last {
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
return
}
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, user)
}
}
// isLastAdmin reports whether id is an administrator and no other user is one.
// A non-admin id is never the last one, so removing them is always allowed.
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
var last bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
return last, err
}
+10 -7
View File
@@ -6,8 +6,15 @@ import (
)
type Config struct {
Addr string
DBPath string
Addr string
// DSN is the Postgres connection string, e.g.
// postgres://terdut:secret@host:5432/terdut?sslmode=require. Required:
// unlike the SQLite path it replaced there is no sensible default, and a
// server that silently came up against the wrong database would be worse
// than one that refuses to start.
DSN string
ArchiveAfter time.Duration
// StaleAfter is how long a firing alert may go without a refreshing webhook
@@ -59,10 +66,6 @@ func Load() Config {
if addr == "" {
addr = ":8080"
}
dbPath := os.Getenv("TERDUT_DB_PATH")
if dbPath == "" {
dbPath = "terdut.db"
}
deadmanMatchers := os.Getenv("TERDUT_DEADMAN_MATCHERS")
if deadmanMatchers == "" {
deadmanMatchers = "alertname=Watchdog"
@@ -73,7 +76,7 @@ func Load() Config {
}
return Config{
Addr: addr,
DBPath: dbPath,
DSN: os.Getenv("TERDUT_DB_DSN"),
ArchiveAfter: duration("TERDUT_ARCHIVE_AFTER", 7*24*time.Hour),
StaleAfter: duration("TERDUT_STALE_AFTER", 6*time.Hour),
+72 -21
View File
@@ -5,37 +5,73 @@ import (
"embed"
"fmt"
"io/fs"
"log"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations
var migrationsFS embed.FS
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
// pingAttempts and pingRetryDelay bound the retry on the first connection.
// This pod's own IP can reach the Postgres pod's node before that node's
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
// event) has added it to the allowed-source set, which fails the ping with
// "connection refused" rather than a timeout. That race resolves within
// several seconds in practice; five attempts two seconds apart give it
// comfortable room without turning a genuinely absent database into a long
// hang.
const (
pingAttempts = 5
pingRetryDelay = 2 * time.Second
)
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
//
// The pool is modest on purpose: this server's concurrency comes from a handful
// of HTTP handlers plus two background loops, and a cloud-native-pg instance
// sized for it has a low max_connections. It is still a pool, unlike the single
// connection SQLite forced, so the notifier no longer blocks a webhook.
func Open(dsn string) (*sql.DB, error) {
if dsn == "" {
return nil, fmt.Errorf("empty DSN: set TERDUT_DB_DSN")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// SQLite does not support concurrent writers; a single connection avoids locking errors.
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;"); err != nil {
db.Close()
return nil, fmt.Errorf("set pragmas: %w", err)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
for attempt := 1; ; attempt++ {
err = db.Ping()
if err == nil {
return db, nil
}
if attempt == pingAttempts {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
time.Sleep(pingRetryDelay)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return db, nil
}
// Migrate applies every embedded migration that has not been applied yet, in
// filename order, recording each in schema_migrations.
//
// Each file runs inside a transaction, which SQLite's version did not do: a
// migration that failed half way used to leave the schema in whatever state it
// had reached. Postgres has transactional DDL, so the rollback is real.
func Migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -55,7 +91,7 @@ func Migrate(db *sql.DB) error {
for _, name := range files {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = $1", name).Scan(&count); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if count > 0 {
@@ -67,13 +103,28 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := db.Exec(string(data)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
if err := applyMigration(db, name, string(data)); err != nil {
return err
}
}
return nil
}
func applyMigration(db *sql.DB, name, body string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
defer tx.Rollback()
if _, err := tx.Exec(body); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
return nil
}
+182
View File
@@ -0,0 +1,182 @@
-- The Postgres baseline: the schema as it stood at the end of the SQLite line,
-- in one file rather than ten.
--
-- The ten SQLite migrations are in git history up to the commit that introduced
-- this one, and they replay against nothing here: their shape was incremental
-- (columns added, then dropped again in 008) and 008's backfill rewrote data
-- that a Postgres install never had. An existing SQLite database is carried over
-- by scripts/sqlite-to-postgres.go, which copies rows into this schema.
--
-- Two conventions inherited deliberately:
--
-- * Timestamps are BIGINT unix seconds, not timestamptz. Everything in Go
-- already speaks epochs, and converting was a second change riding along
-- with the port. Worth revisiting on its own.
--
-- * Ids are GENERATED BY DEFAULT, not ALWAYS, so the migration script can
-- insert rows with their original ids and keep every foreign key intact.
-- setval at the end of the copy puts the sequences past them.
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
-- Where this user's notifications go. NULL means they get none; incidents
-- assigned to them fall back to the configured fallback topic.
ntfy_topic TEXT,
-- NULL means the user has no password and can only use API keys.
password_hash TEXT
);
CREATE TABLE api_keys (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
last_used_at BIGINT
);
-- A session is a browser's credential, the cookie counterpart of an API key:
-- only the hash of the token is stored. expires_at slides forward while the
-- session is in use, so an on-call phone stays signed in.
CREATE TABLE sessions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
user_agent TEXT
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
-- The machine-owned signal record: what Alertmanager says is true right now.
-- Workflow state lives on incidents, never here, because the webhook upsert owns
-- these rows and would overwrite it.
CREATE TABLE alerts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
fingerprint TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('firing', 'resolved')),
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
annotations JSONB NOT NULL DEFAULT '{}'::jsonb,
starts_at BIGINT NOT NULL,
ends_at BIGINT,
generator_url TEXT NOT NULL DEFAULT '',
received_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
archived_at BIGINT,
-- Why the alert left the firing state: 'alertmanager' when a resolved
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
resolution_source TEXT
);
CREATE INDEX alerts_status_idx ON alerts(status);
CREATE INDEX alerts_name_idx ON alerts(name);
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
CREATE TABLE schedule_entries (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
-- The human work item: what people acknowledge, assign, snooze, discuss and
-- resolve. Correlation uses Alertmanager's own groupKey, so incidents follow the
-- group_by routing tree the operator already tuned.
CREATE TABLE incidents (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
group_key TEXT NOT NULL, -- Alertmanager groupKey, opaque
title TEXT NOT NULL, -- rendered from group_labels
group_labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL CHECK (status IN ('triggered', 'acknowledged', 'resolved')),
severity TEXT, -- highest `severity` label across firing members
triggered_at BIGINT NOT NULL,
acknowledged_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at BIGINT,
assigned_to BIGINT REFERENCES users(id) ON DELETE SET NULL,
snoozed_until BIGINT,
resolved_at BIGINT,
resolution_source TEXT, -- 'alerts' | 'manual'
archived_at BIGINT
);
-- Load-bearing: at most one OPEN incident per group_key. This is what makes
-- "resolved incident + a new alert occurrence = a new incident" work, and it is
-- the constraint the webhook's find-or-open lookup relies on.
CREATE UNIQUE INDEX incidents_open_group_key_idx ON incidents(group_key) WHERE resolved_at IS NULL;
CREATE INDEX incidents_status_idx ON incidents(status);
CREATE INDEX incidents_triggered_at_idx ON incidents(triggered_at DESC);
CREATE INDEX incidents_archived_at_idx ON incidents(archived_at);
-- Membership is historical, not a pointer on alerts: one alert row (one
-- fingerprint) resolves and re-fires over time and belongs to a different
-- incident each occurrence.
CREATE TABLE incident_alerts (
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
alert_id BIGINT NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
added_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
PRIMARY KEY (incident_id, alert_id)
);
CREATE INDEX incident_alerts_alert_id_idx ON incident_alerts(alert_id);
-- The timeline. Append-only, and the only history this server keeps: alert rows
-- are mutated in place, so without this there is no record that anything
-- happened. Notes are events too, so one query renders the whole story.
CREATE TABLE incident_events (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- triggered | alert_added | alert_resolved | acknowledged | unacknowledged
-- | assigned | snoozed | unsnoozed | resolved | note | notified | notify_failed
type TEXT NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, -- NULL = the server acted
alert_id BIGINT REFERENCES alerts(id) ON DELETE SET NULL,
detail TEXT,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX incident_events_incident_idx ON incident_events(incident_id, created_at);
-- Delivery is an outbox rather than an inline HTTP call: a POST made while
-- holding the webhook's transaction would hold a connection open across a
-- network round trip. The webhook inserts a row; the notifier goroutine
-- delivers it.
CREATE TABLE notifications (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- Nullable: a notification sent to the fallback topic belongs to nobody,
-- because nobody was on call when the incident opened.
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
topic TEXT NOT NULL, -- resolved at enqueue: who was on call then
kind TEXT NOT NULL CHECK (kind IN ('triggered', 'reminder', 'resolved')),
created_at BIGINT NOT NULL,
send_after BIGINT NOT NULL, -- retry backoff watermark
attempts BIGINT NOT NULL DEFAULT 0,
sent_at BIGINT,
last_error TEXT -- kept after the last attempt, for debugging
);
-- The delivery loop's only query: what is due and still unsent.
CREATE INDEX notifications_pending_idx ON notifications(send_after) WHERE sent_at IS NULL;
-- Reminders and resolved notices both look up an incident's newest row.
CREATE INDEX notifications_incident_idx ON notifications(incident_id, id DESC);
-- A notification body is stored on the ntfy server and cached on the device, so
-- a real API key must never appear in one. Each delivery mints its own token
-- instead: one incident, one action, one day.
CREATE TABLE incident_ack_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 of the raw token, as with api_keys
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX incident_ack_tokens_expires_idx ON incident_ack_tokens(expires_at);
@@ -1,2 +0,0 @@
-- Stage 1 foundation. No tables yet; subsequent migrations add schema.
SELECT 1;
+25
View File
@@ -0,0 +1,25 @@
-- A system administrator role, and the first thing in this server that one user
-- can do and another cannot.
--
-- Until now every authenticated caller could create and delete users, set
-- anybody's password and mint API keys for anybody — auth.go said so in a
-- comment. That was defensible with one operator and a hand-made account; it is
-- not once people sign themselves up (see #7).
--
-- EVERY EXISTING USER BECOMES AN ADMIN. They already hold these powers, so
-- this migration changes nobody's access: it names what is already true, and
-- leaves demotion as a deliberate act somebody performs afterwards. The
-- alternative — promoting only user 1 — would silently strip the others, and
-- could leave an install whose only admin is an account nobody has a password
-- for.
--
-- New users are not admins: the column defaults to false, and the only ways to
-- become one are this backfill, the bootstrap endpoint, or an existing admin
-- granting it.
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false;
UPDATE users SET is_admin = true;
-- The queue's assignment dropdown and the on-call schedule read every user, and
-- the admin screens in #5 will filter on this.
CREATE INDEX users_is_admin_idx ON users(is_admin) WHERE is_admin;
@@ -1,15 +0,0 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_used_at INTEGER
);
-16
View File
@@ -1,16 +0,0 @@
CREATE TABLE alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('firing', 'resolved')),
labels TEXT NOT NULL DEFAULT '{}',
annotations TEXT NOT NULL DEFAULT '{}',
starts_at INTEGER NOT NULL,
ends_at INTEGER,
generator_url TEXT NOT NULL DEFAULT '',
received_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX alerts_status_idx ON alerts(status);
CREATE INDEX alerts_name_idx ON alerts(name);
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
+103
View File
@@ -0,0 +1,103 @@
-- Teams: the unit of tenancy. Everything a person works on now belongs to one.
--
-- Until this migration the install was one shared space — every user saw every
-- alert and every incident, and the Alertmanager webhook was unauthenticated, so
-- anything that could reach the port could open an incident for everybody.
--
-- The shape, in one paragraph: a team owns its incidents, alerts, schedule and
-- integrations. A user belongs to as many teams as they like, with a role in
-- each: an `owner` configures the team, a `member` works its incidents. An
-- integration key is what an alert arrives on, and the key is what says which
-- team the alert belongs to.
--
-- EVERYTHING EXISTING MOVES INTO ONE DEFAULT TEAM, and every existing user
-- becomes an owner of it. That keeps an upgrade a no-op for the people using it:
-- the same queue, the same schedule, the same incidents, with a name on them.
CREATE TABLE teams (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
-- role is free text with a CHECK rather than an enum, so adding a third role
-- later is a migration and not a type rewrite.
CREATE TABLE team_members (
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
joined_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX team_members_user_idx ON team_members(user_id);
-- How alerts get in, and the only thing that says which team they belong to.
-- The key is stored as a SHA-256 hash, like api_keys and the ack tokens: a
-- leaked database gives nobody the ability to post alerts.
CREATE TABLE integrations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('alertmanager')),
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
last_used_at BIGINT
);
CREATE INDEX integrations_team_idx ON integrations(team_id);
-- ---------------------------------------------------------------------------
-- The default team, and everything that already exists moving into it.
--
-- Created unconditionally, even on an empty install, so there is always a team
-- for the bootstrap user to land in and for the first integration to hang off.
-- ---------------------------------------------------------------------------
INSERT INTO teams (name) VALUES ('Default');
INSERT INTO team_members (team_id, user_id, role)
SELECT (SELECT id FROM teams WHERE name = 'Default'), id, 'owner' FROM users;
-- ---------------------------------------------------------------------------
-- team_id on everything a team owns.
--
-- Added nullable, backfilled, then made NOT NULL: adding a NOT NULL column with
-- no default to a table with rows is rejected, and a DEFAULT pointing at the
-- default team would quietly keep working after the default team is gone.
-- ---------------------------------------------------------------------------
ALTER TABLE alerts ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
ALTER TABLE incidents ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
ALTER TABLE schedule_entries ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
UPDATE alerts SET team_id = (SELECT id FROM teams WHERE name = 'Default');
UPDATE incidents SET team_id = (SELECT id FROM teams WHERE name = 'Default');
UPDATE schedule_entries SET team_id = (SELECT id FROM teams WHERE name = 'Default');
ALTER TABLE alerts ALTER COLUMN team_id SET NOT NULL;
ALTER TABLE incidents ALTER COLUMN team_id SET NOT NULL;
ALTER TABLE schedule_entries ALTER COLUMN team_id SET NOT NULL;
-- ---------------------------------------------------------------------------
-- The uniqueness rules were all written for one tenant, and every one of them
-- is wrong now: two teams monitoring two clusters legitimately see the same
-- fingerprint, the same groupKey, and want somebody on call on the same day.
-- ---------------------------------------------------------------------------
ALTER TABLE alerts DROP CONSTRAINT alerts_fingerprint_key;
CREATE UNIQUE INDEX alerts_team_fingerprint_idx ON alerts(team_id, fingerprint);
DROP INDEX incidents_open_group_key_idx;
-- Still load-bearing, now per team: at most one OPEN incident per group_key
-- within a team. This is what makes "resolved incident + a new alert occurrence
-- = a new incident" work, and what the webhook's find-or-open lookup relies on.
CREATE UNIQUE INDEX incidents_open_group_key_idx
ON incidents(team_id, group_key) WHERE resolved_at IS NULL;
ALTER TABLE schedule_entries DROP CONSTRAINT schedule_entries_date_key;
CREATE UNIQUE INDEX schedule_entries_team_date_idx ON schedule_entries(team_id, date);
-- The list views all filter by team first.
CREATE INDEX alerts_team_received_idx ON alerts(team_id, received_at DESC);
CREATE INDEX incidents_team_triggered_idx ON incidents(team_id, triggered_at DESC);
@@ -1,12 +0,0 @@
ALTER TABLE alerts ADD COLUMN acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE alerts ADD COLUMN acknowledged_at INTEGER;
CREATE TABLE alert_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX alert_comments_alert_id_idx ON alert_comments(alert_id);
@@ -0,0 +1,39 @@
-- Dead man's switches become a team's own configuration.
--
-- They were three environment variables — TERDUT_DEADMAN_MATCHERS, _TIMEOUT and
-- _SEVERITY — which made them one setting for the whole install. That was the
-- last piece of the alerting path a team could not control: a team could take
-- its own alerts on its own key and still not say which of them were
-- heartbeats, or how long a silence had to last before somebody was paged.
--
-- One row per team rather than one row per switch. The unit of monitoring is
-- still the fingerprint, as it always was — two clusters sending the same
-- heartbeat alertname are two independent switches — and the matcher string
-- keeps the format the environment variable used, so a value can be moved from
-- one to the other unchanged.
--
-- No rows are seeded here: a migration cannot read the environment. The server
-- inserts a row per team at startup from its own configuration, and the same
-- values therefore carry forward into the first team's row without anybody
-- retyping them. See seedDeadmanConfigs.
CREATE TABLE deadman_configs (
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
-- ";" separates matchers, "," the label conditions within one, "=" is exact
-- equality: `alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat`.
-- Every matcher must name an alertname. Empty watches nothing.
matchers TEXT NOT NULL DEFAULT '',
-- Seconds rather than a Go duration string: the column is compared and
-- arithmetic is done on it, and a value that has to be parsed before it can
-- be believed is a value that can be stored unparseable. Zero disables the
-- team's switches entirely.
timeout_seconds BIGINT NOT NULL DEFAULT 0,
-- The severity these incidents open at. They have no member alerts to
-- derive one from, and a heartbeat's own severity label is meaningless —
-- Watchdog ships as "none".
severity TEXT NOT NULL DEFAULT 'critical',
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
-8
View File
@@ -1,8 +0,0 @@
CREATE TABLE schedule_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
+35
View File
@@ -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;
@@ -1,2 +0,0 @@
ALTER TABLE alerts ADD COLUMN archived_at INTEGER;
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
+95
View File
@@ -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'));
@@ -1,4 +0,0 @@
-- Records why an alert left the firing state: 'alertmanager' when a resolved
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
-- NULL for firing alerts and for rows that predate this migration.
ALTER TABLE alerts ADD COLUMN resolution_source TEXT;
@@ -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;
@@ -0,0 +1,23 @@
-- Similar incidents: a signature per incident, so "has this happened before"
-- is an indexed equality instead of a search.
--
-- The signature is the alert name plus the group labels that identify WHAT is
-- broken, minus the ones that only say WHERE it happened to run this time
-- (instance, pod, ...). Two incidents with the same signature in the same team
-- are the same problem for a responder's purposes.
--
-- Computed in Go for new incidents (incidentSignature in incident_store.go).
-- The backfill below MUST produce the same string; keep the volatile list in
-- both places in step.
ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT '';
UPDATE incidents SET signature =
COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' ||
COALESCE((
SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k)
FROM jsonb_each_text(incidents.group_labels) AS e(k, v)
WHERE e.k <> 'alertname'
AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint')
), '');
CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC);
-123
View File
@@ -1,123 +0,0 @@
-- Splits the single alerts row into two objects, the way an incident management
-- tool needs them: alerts stay the machine-owned signal record that Alertmanager
-- writes, and incidents become the human work item people acknowledge, assign,
-- snooze, discuss and resolve.
--
-- Correlation uses Alertmanager's own groupKey, so incidents follow the group_by
-- routing tree the operator already tuned rather than a second grouping scheme
-- invented here.
CREATE TABLE incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_key TEXT NOT NULL, -- Alertmanager groupKey, opaque
title TEXT NOT NULL, -- rendered from group_labels
group_labels TEXT NOT NULL DEFAULT '{}', -- JSON
status TEXT NOT NULL CHECK(status IN ('triggered', 'acknowledged', 'resolved')),
severity TEXT, -- highest `severity` label across firing members
triggered_at INTEGER NOT NULL,
acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at INTEGER,
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
snoozed_until INTEGER,
resolved_at INTEGER,
resolution_source TEXT, -- 'alerts' | 'manual'
archived_at INTEGER
);
-- Load-bearing: at most one OPEN incident per group_key. This is what makes
-- "resolved incident + a new alert occurrence = a new incident" work, and it is
-- the constraint the webhook's find-or-open lookup relies on.
CREATE UNIQUE INDEX incidents_open_group_key_idx ON incidents(group_key) WHERE resolved_at IS NULL;
CREATE INDEX incidents_status_idx ON incidents(status);
CREATE INDEX incidents_triggered_at_idx ON incidents(triggered_at DESC);
CREATE INDEX incidents_archived_at_idx ON incidents(archived_at);
-- Membership is historical, not a pointer on alerts: one alert row (one
-- fingerprint) resolves and re-fires over time and belongs to a different
-- incident each occurrence.
CREATE TABLE incident_alerts (
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (incident_id, alert_id)
);
CREATE INDEX incident_alerts_alert_id_idx ON incident_alerts(alert_id);
-- The timeline. Append-only, and the only history this server keeps: alert rows
-- are mutated in place, so without this there is no record that anything
-- happened. Notes are events too, so one query renders the whole story.
CREATE TABLE incident_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- triggered | alert_added | alert_resolved | acknowledged | unacknowledged
-- | assigned | snoozed | unsnoozed | resolved | note
type TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = the server acted
alert_id INTEGER REFERENCES alerts(id) ON DELETE SET NULL,
detail TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX incident_events_incident_idx ON incident_events(incident_id, created_at);
-- ---------------------------------------------------------------------------
-- Backfill
--
-- Every pre-existing alert gets its own incident, archived ones included, so no
-- acknowledgement and no comment is orphaned. There is no historical groupKey to
-- correlate on, hence one incident per fingerprint under a 'backfill:' prefix
-- that can never collide with a real Alertmanager groupKey.
-- ---------------------------------------------------------------------------
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at,
acknowledged_by, acknowledged_at, assigned_to,
resolved_at, resolution_source, archived_at)
SELECT 'backfill:' || a.fingerprint,
a.name,
json_object('alertname', a.name),
CASE WHEN a.status = 'resolved' THEN 'resolved'
WHEN a.acknowledged_by IS NOT NULL THEN 'acknowledged'
ELSE 'triggered' END,
json_extract(a.labels, '$.severity'),
a.starts_at,
a.acknowledged_by,
a.acknowledged_at,
a.acknowledged_by,
CASE WHEN a.status = 'resolved' THEN COALESCE(a.ends_at, a.received_at) END,
CASE WHEN a.status = 'resolved' THEN 'alerts' END,
a.archived_at
FROM alerts a;
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
SELECT i.id, a.id, a.starts_at
FROM alerts a
JOIN incidents i ON i.group_key = 'backfill:' || a.fingerprint;
INSERT INTO incident_events (incident_id, type, alert_id, created_at)
SELECT i.id, 'triggered', ia.alert_id, i.triggered_at
FROM incidents i JOIN incident_alerts ia ON ia.incident_id = i.id;
INSERT INTO incident_events (incident_id, type, user_id, created_at)
SELECT i.id, 'acknowledged', i.acknowledged_by, i.acknowledged_at
FROM incidents i WHERE i.acknowledged_at IS NOT NULL;
INSERT INTO incident_events (incident_id, type, created_at)
SELECT i.id, 'resolved', i.resolved_at
FROM incidents i WHERE i.resolved_at IS NOT NULL;
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
SELECT ia.incident_id, 'note', c.user_id, c.alert_id, c.content, c.created_at
FROM alert_comments c
JOIN incident_alerts ia ON ia.alert_id = c.alert_id;
-- ---------------------------------------------------------------------------
-- Workflow state now lives on incidents only. Leaving these behind would keep
-- the bug they caused: the webhook upsert owns the alerts row and never cleared
-- the acknowledgement, so a re-fire days later still read as acknowledged.
-- ---------------------------------------------------------------------------
DROP TABLE alert_comments;
ALTER TABLE alerts DROP COLUMN acknowledged_by;
ALTER TABLE alerts DROP COLUMN acknowledged_at;
@@ -0,0 +1,54 @@
-- Dead man's switches become rows of their own.
--
-- 004 kept a team's switches in one string with one timeout and one severity,
-- which was enough to configure them and not enough to show them: there was no
-- thing to list, nothing to hang a status on, and every switch in a team had to
-- share a deadline. A row per switch gives each its own name, matcher, timeout
-- and severity, and gives the Team → Switches page something to be a list of.
--
-- The matcher keeps the syntax the string used, one matcher per row:
-- `alertname=Watchdog,cluster=prod`. The unit of monitoring is still the
-- fingerprint, so a matcher that many clusters satisfy is still one switch row
-- watching several independent heartbeats.
CREATE TABLE deadman_switches (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- What the owner calls it. Defaults to the matcher when they do not say.
name TEXT NOT NULL,
-- "," separates the label conditions, "=" is exact equality, and alertname is
-- mandatory: it is what keeps the sweeper's candidate query on an index.
matcher TEXT NOT NULL,
-- Seconds of silence before the switch is declared dead. Never zero: a switch
-- that cannot fire is deleted, not disabled.
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
-- The severity its incidents open at. See 004 for why they carry their own.
severity TEXT NOT NULL DEFAULT 'critical',
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX deadman_switches_team_idx ON deadman_switches (team_id);
-- Carry every team's configuration over, one row per matcher. A team whose
-- timeout was zero had switches turned off, which is now "no rows".
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
SELECT c.team_id, btrim(m), btrim(m), c.timeout_seconds, c.severity
FROM deadman_configs c,
LATERAL regexp_split_to_table(c.matchers, ';') AS m
WHERE c.timeout_seconds > 0
AND btrim(m) <> ''
ORDER BY c.team_id;
-- The server seeds environment defaults into teams once, and remembers that it
-- did. An install that had a row per team was already seeded; without this
-- marker the first start after upgrading would seed teams that had switched
-- theirs off.
INSERT INTO settings (key, value)
SELECT 'deadman_seeded', '1'
WHERE EXISTS (SELECT 1 FROM deadman_configs);
DROP TABLE deadman_configs;
@@ -1,44 +0,0 @@
-- Adds push notification delivery, so an incident reaches the person on call
-- instead of waiting to be discovered.
--
-- Delivery is an outbox rather than an inline HTTP call: the pool is limited to
-- a single connection, so a POST made while holding the webhook's transaction
-- would stall every other request behind it. The webhook inserts a row; the
-- notifier goroutine delivers it.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- Nullable: a notification sent to the fallback topic belongs to nobody,
-- because nobody was on call when the incident opened.
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
topic TEXT NOT NULL, -- resolved at enqueue: who was on call then
kind TEXT NOT NULL CHECK(kind IN ('triggered', 'reminder', 'resolved')),
created_at INTEGER NOT NULL,
send_after INTEGER NOT NULL, -- retry backoff watermark
attempts INTEGER NOT NULL DEFAULT 0,
sent_at INTEGER,
last_error TEXT -- kept after the last attempt, for debugging
);
-- The delivery loop's only query: what is due and still unsent.
CREATE INDEX notifications_pending_idx ON notifications(send_after) WHERE sent_at IS NULL;
-- Reminders and resolved notices both look up an incident's newest row.
CREATE INDEX notifications_incident_idx ON notifications(incident_id, id DESC);
-- A notification body is stored on the ntfy server and cached on the device, so
-- a real API key must never appear in one. Each delivery mints its own token
-- instead: one incident, one action, one day.
CREATE TABLE incident_ack_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 of the raw token, as with api_keys
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX incident_ack_tokens_expires_idx ON incident_ack_tokens(expires_at);
-- Where this user's notifications go. NULL means they get none; incidents
-- assigned to them fall back to the configured fallback topic.
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
-18
View File
@@ -1,18 +0,0 @@
-- A password is what lets a person sign in to the web UI. NULL means the user
-- has none and can only use API keys, which is every user created before this.
ALTER TABLE users ADD COLUMN password_hash TEXT;
-- A session is a browser's credential, the cookie counterpart of an API key:
-- only the hash of the token is stored. expires_at slides forward while the
-- session is in use, so an on-call phone stays signed in.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
user_agent TEXT
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
+7 -1
View File
@@ -7,7 +7,13 @@ import "time"
// to it — acknowledgement, assignment, notes and closure all live on the
// Incident an alert belongs to.
type Alert struct {
ID int64 `json:"id"`
ID int64 `json:"id"`
// TeamID is the team whose integration received this alert, and TeamName
// rides along so a combined list can label a row without a second request.
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
Fingerprint string `json:"fingerprint"`
Name string `json:"name"`
Status string `json:"status"` // "firing" or "resolved"
+25
View File
@@ -11,6 +11,19 @@ import "time"
// the webhook and the sweeper may flip to "resolved" once every member alert has
// stopped firing.
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
// incident never moves between teams. TeamName rides along so the combined
// queue can badge each row without a second request.
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
ID int64 `json:"id"`
GroupKey string `json:"group_key"`
Title string `json:"title"`
@@ -66,3 +79,15 @@ type IncidentEvent struct {
Detail *string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SimilarIncident is an earlier, resolved incident with the same signature as
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
// NoteCount counts the plain working notes, which live on the timeline.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}
+8 -1
View File
@@ -3,7 +3,14 @@ package models
import "time"
type ScheduleEntry struct {
ID int64 `json:"id"`
ID int64 `json:"id"`
// TeamID is whose rota this shift belongs to; TeamName rides along so the
// combined "who is on call" view can label each entry without a second
// request.
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD
+54
View File
@@ -0,0 +1,54 @@
package models
import "time"
// Team is the unit of tenancy: it owns its incidents, alerts, schedule and
// integrations, and a user sees exactly the teams they belong to.
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
// Role is the caller's own role in this team, populated when a team is
// listed for a particular person. Empty when nobody in particular is
// asking, as in the admin listing.
Role string `json:"role,omitempty"`
}
// Team roles. An owner configures the team — its schedule, its integrations and
// who is in it. A member works its incidents.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// TeamMember is one person's membership of one team.
type TeamMember struct {
TeamID int64 `json:"team_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
// Integration is how alerts get in, and the only thing that says which team an
// arriving alert belongs to.
type Integration struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Kind string `json:"kind"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// Key is the raw integration key, shown once when the integration is
// created and never stored. URL is the address to point the sender at,
// likewise only complete at creation time.
Key string `json:"key,omitempty"`
URL string `json:"url,omitempty"`
}
// Integration kinds.
const (
IntegrationAlertmanager = "alertmanager"
)
+11
View File
@@ -12,6 +12,17 @@ type User struct {
// none of their own; incidents assigned to them fall back to the configured
// fallback topic instead.
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.
// 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
// it.
IsAdmin bool `json:"is_admin"`
}
type APIKey struct {
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestCopyIncidentIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
for file, want := range map[string]string{
"js/incident.js": "copyIncident",
"js/ui.js": "copy:",
} {
b, err := fs.ReadFile(sub, file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), want) {
t.Errorf("%s lacks %s", file, want)
}
}
}
+320 -9
View File
@@ -31,6 +31,14 @@
--snooze: #6b5bd2;
--snooze-soft: #efedfb;
/* Two hues that mean nothing on their own. The rota needs six colours to
tell six people apart and the palette above only has four that are not
already an alarm. */
--teal: #0f7d8c;
--teal-soft: #e3f4f6;
--pink: #b3427e;
--pink-soft: #fbe8f2;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
@@ -40,7 +48,11 @@
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--topbar-h: 52px;
--tabbar-h: 58px;
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
variable rather than deleted since .view, .toast and .nav's own height
calc still read it. */
--tabbar-h: 0px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@@ -72,6 +84,11 @@
--snooze: #a89bff;
--snooze-soft: #262245;
--teal: #4fc2d4;
--teal-soft: #0f2e33;
--pink: #f07fb8;
--pink-soft: #3a1c2d;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
@@ -183,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.topbar-title {
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
#menu-btn { position: relative; }
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
.open-pill {
display: inline-flex; align-items: center; gap: 6px;
@@ -196,10 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* Bottom tab bar on phones. */
/* Hidden on phones — mobile navigates through the hamburger menu in the
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
left sidebar from 900px, where the desktop block below redeclares display. */
.nav {
display: none;
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
display: grid; grid-template-columns: repeat(4, 1fr);
height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent);
@@ -212,8 +237,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
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; }
.nav-link[aria-current="page"] { color: var(--accent); }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
@@ -285,6 +317,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
min-width: 0;
}
.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); }
/* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team {
padding: 1px 6px; border-radius: 4px;
background: var(--surface-2); border: 1px solid var(--border);
color: var(--muted); font-size: 12px; white-space: nowrap;
}
.row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); }
@@ -317,7 +359,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); }
.badge.st-resolved, .badge.st-healthy { background: var(--ok-soft); color: var(--ok); }
.badge.st-dead { background: var(--crit-soft); color: var(--crit); }
/* Dormant is the plain badge on purpose: nothing has gone wrong and nothing has
gone right, which is what the muted default already says. */
.badge.st-dormant { background: var(--surface-2); color: var(--muted); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); }
@@ -335,6 +381,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.detail-head .copy { margin-left: auto; }
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
@@ -408,11 +456,16 @@ details[open] > summary { margin-bottom: 8px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
.tl-time { color: var(--faint); font-size: 12px; }
.tl-note .note {
.note {
margin-top: 6px; padding: 10px 12px;
background: var(--surface-2); border-radius: var(--radius-sm);
white-space: pre-wrap; overflow-wrap: anywhere;
}
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
.similar { list-style: none; margin: 0; padding: 0; }
.similar-item { padding: 10px 0; }
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
.note-actions { display: flex; justify-content: flex-end; }
.note-actions .btn { color: var(--muted); }
@@ -556,8 +609,6 @@ kbd {
/* ---------- desktop ---------- */
@media (min-width: 900px) {
:root { --tabbar-h: 0px; }
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
.nav {
@@ -593,6 +644,10 @@ kbd {
.view-queue .pane { overflow: auto; height: 100dvh; }
.pane-list { border-right: 1px solid var(--border); }
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
@@ -620,3 +675,259 @@ kbd {
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.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); }
/* --- dead man's switches -------------------------------------------------
Six columns do not fit a phone, so the table scrolls inside its card rather
than the page. A heartbeat under a switch with several is indented, the way
the escalation ladder indents its levels. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.table-scroll { overflow-x: auto; margin-top: 12px; }
.switch-table th, .switch-table td { white-space: nowrap; }
.switch-table td:nth-child(2) { white-space: normal; min-width: 12em; }
.switch-table .source-row td { border-bottom-style: dashed; }
.switch-table .source-row td:first-child { padding-left: 16px; }
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.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; }
/* ---------- stats page ---------- */
#view-stats .chips { padding-left: 0; padding-right: 0; }
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-tile {
--sev: var(--border-strong);
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--sev); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 12px;
}
.stat-tile.st-triggered { --sev: var(--crit); }
.stat-tile.st-acknowledged { --sev: var(--warn); }
.stat-tile.st-resolved { --sev: var(--ok); }
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
.chart-card + .chart-card { margin-top: 0; }
.chart-title {
margin-bottom: 10px; font-size: 13px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
}
.hbars { list-style: none; display: grid; gap: 6px; }
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.columns { display: block; width: 100%; height: auto; }
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
.columns .col-hit { fill: transparent; }
.columns .col-bar { fill: var(--accent); }
.columns .col:hover .col-bar { opacity: 0.75; }
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
@media (min-width: 900px) {
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
}
+66 -5
View File
@@ -37,6 +37,37 @@
<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
<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>
</main>
@@ -46,27 +77,48 @@
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</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>
<span class="nav-label">Queue</span>
<span class="nav-badge" data-badge hidden></span>
</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>
<span class="nav-label">On-call</span>
</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>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/more" data-section="more">
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
</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>
<span class="nav-label">Account</span>
</a>
</nav>
<header class="topbar">
<h1 class="topbar-title" id="topbar-title">Queue</h1>
<div class="topbar-left">
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
<span class="open-pill" id="open-pill" hidden></span>
</header>
@@ -80,6 +132,15 @@
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
<section id="view-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
list, and a section of its own so a deep link survives a reload. -->
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
which the Team tab cannot show for a team you are not a member of. -->
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
+84 -1
View File
@@ -23,6 +23,9 @@ function render() {
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
@@ -32,10 +35,90 @@ function render() {
h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
);
}
// Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator.
//
// The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks.
function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false,
value: user.ntfy_topic || '',
placeholder: 'terdut-a7f3c91e',
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
h('label', {},
h('span', { text: 'ntfy topic' }),
topic),
h('p', { class: 'muted small' },
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
'reach your phone. Leave it empty and they page the team’s fallback ',
'topic instead.'),
// Worth saying plainly: people reach for their own name, and the topic is
// the only thing standing between a stranger and their pages.
h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'),
err, ok,
h('div', { class: 'row-actions' }, submit, test),
);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
submit.disabled = true;
try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload.
state.me.user = updated;
ok.textContent = updated.ntfy_topic
? 'Topic saved.'
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_topic;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return form;
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
+368
View File
@@ -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)),
);
}
+341
View File
@@ -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();
}
+294
View File
@@ -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();
}
+85 -11
View File
@@ -67,6 +67,15 @@ export const setPassword = (userID, password, currentPassword) =>
// users
export const users = () => call('GET', '/users');
// What one person is in. /teams answers "what am I in" and cannot be asked
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`);
@@ -74,25 +83,90 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// stats
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
// alerts
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule
export const schedule = (from, to) => call('GET', '/schedule', { query: { from, to } });
export async function onCallNow() {
try {
return await call('GET', '/schedule/current');
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
// 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 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 deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
export const createDeadmanSwitch = (id, body) =>
call('POST', `/teams/${id}/deadman/switches`, { body });
export const deleteDeadmanSwitch = (id, switchID) =>
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
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) =>
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
// One entry per team the viewer belongs to, for the teams that have somebody
// scheduled today. An empty array means nobody anywhere, which is a real answer
// rather than an error — unlike the pre-teams endpoint, which 404ed.
export const onCallNow = () => call('GET', '/schedule/current');
+197 -12
View File
@@ -3,31 +3,79 @@
import * as api from './api.js';
import * as ui from './ui.js';
import * as poll from './poll.js';
import { state, reset } from './state.js';
import { state, reset, loadTeams } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open.
// One route per section; /incidents/{id} is the queue with a detail open, and
// /admin/users/{id} is a section of its own rather than a mode of the Admin
// tab, because it replaces the page rather than opening beside it.
const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
stats: { title: 'Stats', view: stats },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
more: { title: 'Account', view: account },
};
// The mobile hamburger menu's contents — the same sections the desktop
// sidebar's .nav-link list carries in index.html, in the same order.
const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
];
function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
if (u) return { section: 'adminuser', user: Number(u[1]) };
// Before the TABS lookup below, which matches a path exactly and would let
// /admin/teams/7 fall through to the queue.
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
if (g) return { section: 'adminteam', team: Number(g[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
// The Admin and Team tabs' sub-sections are routes of their own. Each view
// owns the table of its own, since each also builds the strip that links to
// them; /team is in team.TABS as the overview, so it is matched here too.
const t = admin.TABS.find((x) => x.path === `/${name}`);
if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
// What the top bar and the document title call this route. The sub-sections of
// Admin and Team are pages in their own right, so they say which one rather
// than the tab's name four or six times; either overview keeps the tab's own
// name. A tab may carry a `title` where its strip label is too short to name a
// page on its own.
function title(r) {
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
return t ? (t.title || t.label) : SECTIONS[r.section].title;
}
let route = parseRoute(location.pathname);
// How many in-app navigations deep we are, so Back can use the browser's
// history when there is somewhere to go back to, and the queue otherwise.
@@ -60,13 +108,16 @@ function render() {
route = parseRoute(location.pathname);
const app = $('app');
for (const [name, s] of Object.entries(SECTIONS)) {
for (const name of Object.keys(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title;
if (name === route.section) $('topbar-title').textContent = title(route);
}
// A section may light up somebody else's tab: /admin/users/{id} is still the
// Admin tab as far as the nav is concerned, since there is no tab of its own.
const current = SECTIONS[route.section].nav || route.section;
for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
@@ -81,16 +132,49 @@ function render() {
incident.show(route.incident);
} else {
incident.show(null);
SECTIONS[route.section].view.show();
SECTIONS[route.section].view.show(route);
}
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
else if (prev.section !== route.section) window.scrollTo(0, 0);
// A changed tab counts as a changed page: stepping from a long user list to
// the settings should not land you halfway down them. So does a changed
// subject — one team to the next is two pages, not one scrolled page.
else if (prev.section !== route.section || prev.tab !== route.tab
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
updateTitle();
}
// ---------- nav menu ----------
// The mobile hamburger menu: same shape as the sheet-based action menus in
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
function openNavMenu() {
const current = SECTIONS[route.section].nav || route.section;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
ui.openSheet(() => [
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
ui.h('li', {}, ui.h('button', {
class: 'menu-item',
type: 'button',
role: 'menuitemradio',
'aria-checked': String(n.section === current),
onclick: () => ui.closeSheet(n.path),
},
ui.icon(n.icon),
n.label,
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
))),
),
]).then((path) => {
if (path) navigate(path);
});
}
// ---------- refresh + badges ----------
async function refresh() {
@@ -117,15 +201,18 @@ function updateBadges() {
pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
const badge = document.querySelector('[data-badge]');
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
// hamburger button (mobile) — only one of the two is ever visible at once.
for (const badge of document.querySelectorAll('[data-badge]')) {
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
}
updateTitle();
}
function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = SECTIONS[route.section].title;
const section = title(route);
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base;
}
@@ -138,9 +225,22 @@ async function boot() {
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
$('boot').hidden = true;
await showSignup();
return;
}
try {
state.me = await api.me();
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();
} catch (err) {
if (err.status === 401) showLogin();
@@ -153,6 +253,84 @@ function showBootError(err) {
$('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() {
poll.stop();
ui.closeSheet(null);
@@ -160,8 +338,15 @@ function showLogin() {
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form');
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.username.value ? form.password : form.username).focus();
}
+176 -15
View File
@@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail');
let currentID = null;
let inc = null;
let events = [];
let similarList = [];
let error = null;
let busy = false;
@@ -38,10 +39,15 @@ export async function refresh() {
const id = currentID;
if (id == null) return;
try {
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
// Similar incidents are a courtesy: an older server answers 404 and a
// failure here must not hide the incident itself.
const [i, t, sim] = await Promise.all([
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
]);
if (id !== currentID) return;
inc = i;
events = t;
similarList = sim;
error = null;
} catch (err) {
if (id !== currentID) return;
@@ -60,6 +66,8 @@ function render() {
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
icon('back')),
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
icon('copy')),
);
if (!inc) {
@@ -81,6 +89,7 @@ function render() {
facts(),
groupLabels(),
alertsSection(),
similarSection(),
timelineSection(),
),
actionBar(),
@@ -95,6 +104,15 @@ function statusBadges() {
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
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'));
return out;
}
@@ -115,6 +133,10 @@ function facts() {
if (inc.status !== 'resolved' && isFuture(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) {
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
@@ -166,8 +188,9 @@ function alertItem(a) {
// ---------- timeline ----------
function eventText(ev) {
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
// named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) {
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
@@ -184,6 +207,7 @@ function eventText(ev) {
case 'unsnoozed': return [strong(person), ' ended the snooze'];
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
case 'note': return [strong(person), ' added a note'];
case 'resolution_note': return [strong(person), ' noted what fixed it'];
case 'notified': {
const to = person ? strong(person) : 'the fallback topic';
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
@@ -196,6 +220,22 @@ function eventText(ev) {
}
}
// Earlier incidents with the same signature that someone left notes on, the
// ones that recorded what fixed it first. Plain notes are on that incident's
// own page.
function similarSection() {
if (!similarList.length) return null;
return h('section', { class: 'section' },
h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })),
h('div', { class: 'card' },
h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' },
h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }),
h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }),
...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })),
)))),
);
}
function timelineSection() {
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
return h('section', { class: 'section' },
@@ -210,20 +250,116 @@ function timelineSection() {
);
}
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
function timelineItem(ev) {
const mine = ev.type === 'note' && ev.user_id === myID();
const mine = isNote(ev) && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` },
h('span', { class: 'tl-dot' }),
h('div', { class: 'tl-body' },
h('div', { class: 'tl-text' }, eventText(ev)),
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : 'note', text: ev.detail || '' }),
mine && h('div', { class: 'note-actions' },
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
),
);
}
// ---------- copy ----------
const fence = (rows) => ['```', ...rows, '```'];
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
// incidentMarkdown is everything on this page as text that reads well in a chat
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
// it has been pasted somewhere else.
function incidentMarkdown() {
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
add('Status', inc.status);
add('Severity', inc.severity);
add('Team', inc.team_name);
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
add('Triggered', inc.triggered_at);
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
if (inc.archived_at) add('Archived', inc.archived_at);
const group = pairs(inc.group_labels);
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
const alerts = inc.alerts || [];
out.push('', `## Alerts (${alerts.length})`);
for (const a of alerts) {
out.push('', `### ${a.name} (${a.status})`);
out.push(`- Started: ${a.starts_at}`);
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
const labels = pairs(a.labels);
if (labels.length) out.push('', 'Labels:', ...fence(labels));
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
}
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
if (sorted.length) {
out.push('', '## Timeline', '');
for (const ev of sorted) {
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
out.push(`- ${ev.created_at} ${text}`);
if (isNote(ev) && ev.detail) {
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
}
}
}
if (similarList.length) {
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
for (const s of similarList) {
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
for (const n of s.resolution_notes || []) {
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
}
}
}
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
return out.join('\n');
}
// writeClipboard falls back to execCommand: the async API needs a secure
// context, and this server is often reached over plain HTTP.
async function writeClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// fall through
}
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
ta.value = text;
document.body.append(ta);
ta.select();
try {
if (!document.execCommand('copy')) throw new Error('copy refused');
} finally {
ta.remove();
}
}
async function copyIncident() {
if (!inc) return;
try {
await writeClipboard(incidentMarkdown());
toast('Copied incident');
} catch {
toast('Could not copy', 'error');
}
}
// ---------- actions ----------
const isOpen = () => inc.status !== 'resolved';
@@ -283,15 +419,35 @@ function unacknowledge() {
async function resolve() {
const id = inc.id;
const ok = await confirm({
title: 'Resolve this incident?',
text: 'Resolving is final. If these alerts fire again they open a new incident, '
+ 'and if any are still firing this one stays closed regardless. '
+ 'Use snooze if you only need it out of the way.',
confirmLabel: 'Resolve',
danger: true,
const res = await openSheet(() => {
const textarea = h('textarea', {
name: 'resolution', autofocus: true, maxlength: '10000',
placeholder: 'What fixed it? Optional, shown on the next similar incident.',
});
const form = h('form', {
class: 'sheet-form',
onsubmit: (e) => {
e.preventDefault();
closeSheet({ resolution: textarea.value.trim() });
},
},
h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }),
h('p', {
text: 'Resolving is final. If these alerts fire again they open a new incident, '
+ 'and if any are still firing this one stays closed regardless. '
+ 'Use snooze if you only need it out of the way.',
}),
textarea,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })),
);
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
});
return form;
});
if (ok) await run(() => api.resolve(id), 'Resolved');
if (res) await run(() => api.resolve(id, res.resolution), 'Resolved');
}
function archive() {
@@ -369,16 +525,18 @@ async function addNote() {
const textarea = h('textarea', {
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
});
const fix = h('input', { type: 'checkbox', name: 'fix' });
const form = h('form', {
class: 'sheet-form',
onsubmit: (e) => {
e.preventDefault();
const v = textarea.value.trim();
if (v) closeSheet(v);
if (v) closeSheet({ content: v, pinned: fix.checked });
},
},
h('h2', { class: 'sheet-title', text: 'Add note' }),
textarea,
h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
@@ -389,7 +547,7 @@ async function addNote() {
});
return form;
});
if (content) await run(() => api.addNote(id, content), 'Note added');
if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added');
}
async function deleteNote(ev) {
@@ -409,10 +567,12 @@ async function moreMenu() {
items.push(item('user', 'Assign…', assign));
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
@@ -441,6 +601,7 @@ export function key(e) {
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
case 'Z': if (isSnoozed()) unsnooze(); return true;
case 'c': addNote(); return true;
case 'y': copyIncident(); return true;
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
default: return false;
}
+147
View File
@@ -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;
}
+39 -11
View File
@@ -1,10 +1,14 @@
// On-call: who is on duty now, the week around it, and your own next shifts.
// Read-only for now; the TUI edits the schedule.
//
// One team's rota at a time — the viewer's first team, since a viewer in one
// team has nothing to choose between. "On call now" is the exception and shows
// every team the viewer is in, because somebody on two rotas wants both.
import * as api from './api.js';
import { h, clear, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
import { myID } from './state.js';
import { myID, currentTeam } from './state.js';
const view = () => document.getElementById('view-oncall');
@@ -24,10 +28,17 @@ export async function refresh() {
const start = weekStart;
const today = new Date();
try {
const team = currentTeam();
if (!team) {
data = { now: [], week: [], upcoming: [] };
error = null;
render();
return;
}
const [now, week, upcoming] = await Promise.all([
api.onCallNow(),
api.schedule(isoDate(start), isoDate(addDays(start, 6))),
api.schedule(isoDate(today), isoDate(addDays(today, 60))),
api.schedule(team.id, isoDate(start), isoDate(addDays(start, 6))),
api.schedule(team.id, isoDate(today), isoDate(addDays(today, 60))),
]);
if (start !== weekStart) return;
data = { now, week, upcoming };
@@ -60,15 +71,32 @@ function you(userID) {
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
}
// One card per team with somebody on call, and a single empty card when there
// is nobody anywhere. The team's name is shown only when the viewer is in more
// than one, so the common case reads exactly as it did before teams existed.
function nowCard() {
const n = data.now;
return h('div', { class: 'card now-card' },
h('div', { class: `avatar ${n ? '' : 'none'}`, text: n ? initial(n.username) : '–' }),
h('div', {},
h('div', { class: 'now-label', text: 'On call now' }),
h('div', { class: 'now-name' }, n ? n.username : 'Nobody', n && you(n.user_id)),
),
);
const entries = data.now || [];
const showTeam = entries.length > 1;
if (entries.length === 0) {
return h('div', { class: 'card now-card' },
h('div', { class: 'avatar none', text: '–' }),
h('div', {},
h('div', { class: 'now-label', text: 'On call now' }),
h('div', { class: 'now-name', text: 'Nobody' }),
),
);
}
return h('div', {}, ...entries.map((n) =>
h('div', { class: 'card now-card' },
h('div', { class: 'avatar', text: initial(n.username) }),
h('div', {},
h('div', {
class: 'now-label',
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
}),
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
),
)));
}
function weekCard() {
+71 -6
View File
@@ -4,6 +4,7 @@ import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
@@ -25,13 +26,35 @@ const EMPTY = {
archived: ['Nothing archived', ''],
};
onboarding.onRerender(() => renderList());
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadTeamFilter() {
try {
return sessionStorage.getItem('terdut.queue.team') || '';
} catch {
return '';
}
}
function setTeamFilter(id) {
teamFilter = id;
try {
sessionStorage.setItem('terdut.queue.team', id);
} catch {
/* storage unavailable */
}
renderChips();
refresh({ fresh: true });
}
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
@@ -64,7 +87,12 @@ export async function refresh({ fresh = false } = {}) {
const requested = filter;
try {
// The open list is already fetched for the badges; no need to ask twice.
const result = filter === 'open' && !fresh ? state.open : await api.incidents(f.query);
// The cached open queue covers every team, so it can only be reused when
// no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
items = result;
error = null;
@@ -88,7 +116,7 @@ function setFilter(id) {
function renderChips() {
const el = document.getElementById('queue-filters');
clear(el, FILTERS.map((f) =>
const chips = FILTERS.map((f) =>
h('button', {
class: 'chip',
type: 'button',
@@ -97,25 +125,54 @@ function renderChips() {
onclick: () => setFilter(f.id),
text: f.label,
}),
));
);
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
// them: the combined queue is the point.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === ''),
onclick: () => setTeamFilter(''),
text: 'All teams',
}));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === String(team.id)),
onclick: () => setTeamFilter(String(team.id)),
text: team.name,
}));
}
}
clear(el, chips);
}
function renderList() {
const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error }));
clear(el, checklist, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, spinner());
clear(el, checklist, spinner());
return;
}
if (!items.length) {
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;
}
clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);
@@ -142,6 +199,13 @@ function row(inc, index) {
// The server already puts the group labels in the title; show only the rest.
const labels = labelSummary(Object.fromEntries(
Object.entries(inc.group_labels || {}).filter(([k, v]) => !inc.title.includes(`${k}=${v}`))));
// The team is shown only to somebody who is in more than one. For everybody
// else it is the same word on every row, which is noise rather than
// information.
const team = state.teams.length > 1 && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name })
: null;
return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`,
@@ -153,6 +217,7 @@ function row(inc, index) {
h('div', { class: 'row-meta' },
status,
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
),
);
+13
View File
@@ -6,8 +6,15 @@ import * as api from './api.js';
export const state = {
me: null, // { user, has_password }
open: [], // the default queue: open, not snoozed
teams: [], // the teams the viewer belongs to, each with their role
};
// The team whose schedule and settings the views act on. A viewer in one team —
// which is everybody until somebody makes a second — never has to choose.
export function currentTeam() {
return state.teams[0] || null;
}
export function myID() {
return state.me ? state.me.user.id : null;
}
@@ -24,8 +31,14 @@ export async function users() {
return usersCache;
}
export async function loadTeams() {
state.teams = await api.teams();
return state.teams;
}
export function reset() {
state.me = null;
state.open = [];
state.teams = [];
usersCache = null;
}
+168
View File
@@ -0,0 +1,168 @@
// Statistics: how many incidents, how fast they are answered, and when and
// what the alerts are. The same figures the TUI's Stats tab shows, over a
// range picked with the chips. The server scopes them to the caller's teams.
import * as api from './api.js';
import { h, clear, emptyState, spinner } from './ui.js';
import { duration } from './format.js';
const DAY_MS = 24 * 60 * 60 * 1000;
// `days` counts back from today, inclusive; the server reads from/to as UTC
// dates, so these are too.
const RANGES = [
{ id: 'today', label: 'Today', days: 1 },
{ id: '7d', label: '7d', days: 7 },
{ id: '30d', label: '30d', days: 30 },
{ id: '90d', label: '90d', days: 90 },
{ id: 'all', label: 'All', days: null },
];
const view = () => document.getElementById('view-stats');
let range = '30d';
let data = null;
let error = null;
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
function query(r) {
if (!r.days) return {};
const now = Date.now();
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
}
export function show() {
render();
refresh();
}
export async function refresh() {
const requested = range;
const q = query(RANGES.find((x) => x.id === range));
try {
const [incidents, top, byHour, byDay] = await Promise.all([
api.statsIncidents(q),
api.statsTop({ ...q, limit: 10 }),
api.statsByHour(q),
api.statsByDay(q),
]);
if (requested !== range) return;
data = { incidents, top, byHour, byDay };
error = null;
} catch (err) {
if (requested !== range) return;
error = err.message;
}
render();
}
function setRange(id) {
if (id === range) return;
range = id;
data = null;
render();
refresh();
}
function render() {
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
RANGES.map((r) => h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(r.id === range),
onclick: () => setRange(r.id),
text: r.label,
})));
let body;
if (error && !data) body = h('div', { class: 'load-error', text: error });
else if (!data) body = spinner();
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
body = emptyState('No data in this range', '', 'chart');
} else {
body = h('div', { class: 'stats' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
tiles(data.incidents),
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
card('Alerts by hour (UTC)', columns(
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
'Alerts by hour of day')),
card('Alerts by day', columns(
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
'Alerts by day of week')));
}
clear(view(), h('div', {}, chips, body));
}
// A missing mean means nothing has been acknowledged or resolved yet.
const mean = (s) => (s == null ? '—' : duration(s * 1000));
function tiles(s) {
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
h('div', { class: 'stat-value', text: String(value) }),
h('div', { class: 'stat-label', text: label }));
return h('div', { class: 'stat-tiles' },
tile('Incidents', s.total),
tile('Triggered', s.triggered, 'st-triggered'),
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
tile('Resolved', s.resolved, 'st-resolved'),
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
tile('Mean time to resolve', mean(s.mttr_seconds)));
}
function card(title, content) {
return h('section', { class: 'chart-card card card-pad' },
h('h3', { class: 'chart-title', text: title }), content);
}
// Ranked names with a bar scaled to the busiest one.
function topAlerts(items) {
const max = Math.max(...items.map((x) => x.count), 1);
return h('ol', { class: 'hbars' }, items.map((x) => {
const fill = h('span', { class: 'hbar-fill' });
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
return h('li', { class: 'hbar' },
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
h('span', { class: 'hbar-track' }, fill),
h('span', { class: 'hbar-count', text: String(x.count) }));
}));
}
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs = {}, text) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text != null) el.textContent = text;
return el;
}
// A column chart: one bar per item, the value in a tooltip, and a label under
// the items marked `tick`.
function columns(items, label) {
const W = 480;
const H = 140;
const base = H - 18;
const step = W / items.length;
const max = Math.max(...items.map((x) => x.value), 1);
const root = svg('svg', {
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
});
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
items.forEach((it, i) => {
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
const x = i * step + step * 0.15;
const g = svg('g', { class: 'col' });
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
// A full-height transparent hit area, so a tiny bar is still hoverable.
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
root.appendChild(g);
if (it.tick) {
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
}
});
return root;
}
+849
View File
@@ -0,0 +1,849 @@
// 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, badge, labelChip } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, initial, ago, when, duration } 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.deadmanSwitches(id) };
const grid = gridDays();
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
api.teamMembers(id),
api.integrations(id),
api.escalation(id),
api.deadmanSwitches(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 || []).length;
const dead = (data.deadman || []).filter((s) => s.status === 'dead').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
? (dead ? `${dead} of them silent.` : 'All quiet, as they should be.')
: '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 ---------------------------------------------------
const SWITCH_STATUS = {
healthy: { label: 'Healthy', hint: 'Heard from within its timeout.' },
dead: { label: 'Dead', hint: 'Silent for longer than its timeout.' },
dormant: { label: 'Dormant', hint: 'Nothing has matched yet, so there is nothing to lose.' },
};
function switchBadge(status) {
const s = SWITCH_STATUS[status] || SWITCH_STATUS.dormant;
const el = badge(s.label, `st-${status}`);
el.title = s.hint;
return el;
}
const timeCell = (iso) => iso
? h('span', { title: when(iso), text: ago(iso) })
: h('span', { class: 'muted', text: 'never' });
// When it last opened an incident. An incident that is still open is a link,
// because that is the thing somebody looking at a red row wants next.
const triggeredCell = (iso, incidentID) => {
if (!iso) return h('span', { class: 'muted', text: 'never' });
return incidentID
? h('a', { href: `/incidents/${incidentID}`, title: when(iso) }, `#${incidentID} · ${ago(iso)}`)
: h('span', { title: when(iso), text: ago(iso) });
};
function switchRows(sw) {
const main = h('tr', {},
h('td', {}, switchBadge(sw.status)),
h('td', {},
h('strong', { text: sw.name }),
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
if (!(await confirm({
title: `Remove ${sw.name}?`,
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
},
})),
);
// One heartbeat is the switch's own times; several are worth telling apart,
// since a live cluster must not hide a dead one.
const sources = sw.sources.length > 1
? sw.sources.map((src) => h('tr', { class: 'source-row' },
h('td', {}, switchBadge(src.status)),
h('td', { class: 'source-labels' },
...Object.entries(src.labels || {})
.filter(([k]) => k !== 'alertname')
.map(([k, v]) => labelChip(k, v)),
!Object.keys(src.labels || {}).some((k) => k !== 'alertname')
&& h('code', { class: 'small', text: src.fingerprint })),
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
h('td'), h('td')))
: [];
return [main, ...sources];
}
function deadmanCard() {
const switches = data.deadman || [];
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
})),
h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
'quiet for longer than the switch’s timeout opens an incident.'),
switches.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table switch-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
h('th', { text: 'Silent after' }), h('th'))),
h('tbody', {}, switches.flatMap(switchRows))))
: h('p', { class: 'muted', text: 'Nothing watched.' }),
);
}
// The form lives in the sheet, not on the page: most visits are to look at the
// list, and a form that is always open is the page this replaced.
function openNewSwitch() {
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
const matcher = h('input', {
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
});
const timeout = h('input', {
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
});
const severity = h('select', {},
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Name (optional) ', name),
h('label', {}, 'Heartbeat alert ', matcher),
h('p', { class: 'muted small' },
'Conditions are ', h('code', { text: 'label=value' }), ' separated by commas, and one ',
'must be ', h('code', { text: 'alertname' }), '. Every distinct label set that ',
'matches is watched on its own.'),
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
h('label', {}, 'Open the incident at severity ', severity),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.createDeadmanSwitch(teamID, {
name: name.value.trim(),
matcher: matcher.value.trim(),
timeout_seconds: Math.round(Number(timeout.value) * 60),
severity: severity.value,
});
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
}
// --- 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();
}
+26
View File
@@ -34,15 +34,21 @@ export function clear(el, ...children) {
const ICONS = {
back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'],
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
check: ['M5 12.5l4.5 4.5L19 7'],
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'],
@@ -64,6 +70,14 @@ export function icon(name, cls = 'icon') {
node.setAttribute('cx', cx);
node.setAttribute('cy', cy);
node.setAttribute('r', r);
} else if (d.startsWith('rect:')) {
const [x, y, w, hgt, rx] = d.slice(5).split(',');
node = document.createElementNS(SVG, 'rect');
node.setAttribute('x', x);
node.setAttribute('y', y);
node.setAttribute('width', w);
node.setAttribute('height', hgt);
if (rx) node.setAttribute('rx', rx);
} else {
node = document.createElementNS(SVG, 'path');
node.setAttribute('d', d);
@@ -160,6 +174,18 @@ export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
}
// One entry in a section's overview: a card that is a link, carrying the count
// only that section can state. Both the Admin tab and the Team tab open on one
// of these menus, and a menu item is a shape rather than a page's own idea.
export function menuCard(href, label, count, note) {
return h('a', { class: 'card overview-item', href },
h('div', { class: 'overview-head' },
h('strong', { text: label }),
count != null && h('span', { class: 'overview-count', text: String(count) })),
h('p', { class: 'muted small', text: note }),
);
}
export function emptyState(title, text, iconName) {
return h('div', { class: 'empty' },
iconName && icon(iconName),
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestStatsPageIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
if !strings.Contains(string(index), want) {
t.Errorf("index.html lacks %s", want)
}
}
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
t.Errorf("js/stats.js not embedded: %v", err)
}
}