Compare commits

...

22 Commits

Author SHA1 Message Date
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
Niklas Ye 989425e550 Set the chart's placeholder version to 0.10.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 28s
CI / test (push) Successful in 2m13s
Release / test (push) Successful in 1m9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 1m41s
Release / image (push) Successful in 1m42s
Release / scan-image (push) Successful in 2s
Cosmetic, as in e78f494 and 4cec26e. `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.10.2 that still says 0.10.1
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-19 18:05:06 +02:00
Niklas Ye 5b1ab2c568 Move x/crypto to v0.55.0, clear of the ssh CVEs
v0.10.1's image published, but scan-image refused it. trivy reports ten
HIGH advisories against golang.org/x/crypto v0.49.0, CVE-2026-39828
through CVE-2026-56854, all of them in x/crypto/ssh and its agent and
knownhosts packages. The last is fixed in 0.55.0 and the rest in 0.52.0.

None of them is reachable here. The server imports x/crypto/bcrypt and
nothing else from the module, and the image is FROM scratch, holding a
single binary. But trivy scans at module granularity and cannot tell
that. Shipping a known-vulnerable module version on the strength of a
reachability argument is not the call to make inside a dependency pin,
so the version moves instead.

9abf07f was wrong to call v0.49.0 the newest release that keeps go.mod
at go 1.25.9. v0.55.0 keeps it there too; the directive is unchanged
here. What had held it at 1.26 was the x/sys v0.48.0 that v0.57.0 pulled
in. The `go get golang.org/x/sys@v0.42.0` meant to undo that then
downgraded x/crypto to v0.49.0 to match, without being asked. x/sys now
sits at v0.47.0, the version x/crypto v0.55.0 requires.

Checked before tagging rather than by the pipeline: the gate passes, the
Dockerfile builds on its Go 1.25 builder, and trivy, run locally with
the same flags as `make security-image` (HIGH and CRITICAL, fixed only),
exits 0 on the image that build produced.
2026-09-19 18:05:06 +02:00
Niklas Ye e78f49461a Set the chart's placeholder version to 0.10.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 27s
CI / test (push) Successful in 2m11s
Release / test (push) Successful in 1m11s
Release / chart (push) Successful in 3s
Release / binaries (push) Successful in 1m32s
Release / image (push) Successful in 1m41s
Release / scan-image (push) Failing after 29s
Cosmetic, as in 4cec26e and 9669b8f. `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.10.1 that still says 0.10.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-19 17:56:20 +02:00
Niklas Ye 9abf07f2cb Build on Go 1.25 again, as the Dockerfile does
v0.10.0 never produced an image. Adding bcrypt in dc3879e ran
`go get golang.org/x/crypto`, which took the newest release, v0.57.0.
That version declares `go 1.26.0`, and so does the x/sys v0.48.0 it
pulled along, so go.mod's own directive rose from 1.25.9 to 1.26.0. The
Dockerfile builds on golang:1.25-alpine with GOTOOLCHAIN=local, and
refused:

  go: go.mod requires go >= 1.26.0 (running go 1.25.14; GOTOOLCHAIN=local)

Nothing before the image job could see it. The local toolchain is 1.26,
and the pipeline's test job runs on the runner's Go rather than in the
Dockerfile's builder. So the gate passed on a module the image could not
build.

Pinned to x/crypto v0.49.0 rather than moving the builder to 1.26. That
is the newest release whose own requirements leave go.mod at 1.25.9 and
x/sys where it was. Changing the toolchain the image is built with
belongs in its own change, not inside a dependency addition. go.mod now
differs from v0.9.4 by the one crypto line and nothing else. bcrypt has
not changed in any way that matters here across those releases.

Checked by building the Dockerfile locally, which now succeeds. The
resulting container serves /incidents/3 as the web UI and answers
/api/me with a JSON 401.
2026-09-19 17:56:20 +02:00
Niklas Ye 4cec26edde Set the chart's placeholder version to 0.10.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 40s
CI / test (push) Successful in 2m47s
Release / test (push) Successful in 1m9s
Release / chart (push) Successful in 2s
Release / image (push) Failing after 42s
Release / scan-image (push) Has been skipped
Release / binaries (push) Successful in 1m57s
Cosmetic, as in 9669b8f and f46e5f5. `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.10.0 that still says 0.9.4
tells a reader something false, and the tree is what gets read before the
tag exists. appVersion keeps the v, per APPVERSION_PREFIX in .release.conf.
2026-09-19 17:48:28 +02:00
Niklas Ye dc3879eca6 Serve a web UI for the incident queue, built for phones
Whoever is on call gets paged on a phone, and until now the only ways to
act on a page were the notification's Acknowledge button or a terminal.
Tapping the notification itself opened /api/incidents/{id}, which a
browser can only answer with a 401 in JSON. The server now serves a web
UI at / covering the incident queue, each incident's alerts and timeline
with every action on it, who is on call, the alert feed, and changing
your own password. The notification link now points at /incidents/{id}
in that UI.

It is embedded in the binary and has no build step: plain HTML, CSS and
ES modules under internal/web/static, served with an ETag per file and a
CSP that allows nothing from any other origin. That is how rd-web is
built. It avoids adding a node toolchain to the Dockerfile and the
pipeline for a page this size, and it keeps the page on the same origin
as the API, so no CORS is needed and nothing else has to be deployed.
Paths without a file extension fall back to index.html, so a deep link
survives a reload. An unknown path under /api/ still gets a JSON 404
rather than the page.

Signing in uses a username and password, because pasting a 64-character
API key into a phone at 3am is not a sign-in flow. Users have no
password until one is set through PUT /api/users/{id}/password, or
optionally at bootstrap. A user without a password is exactly where they
were before this commit and can only use API keys. A login sets an
HttpOnly, SameSite=Lax session cookie. It lasts 30 days and slides
forward while in use, so an on-call phone does not sign itself out.
Only the token's hash is stored, as for API keys.

The cookie needs a CSRF guard where a bearer header does not, because
browsers attach cookies to requests other sites make. So cookie-
authenticated requests go through Go 1.25's http.CrossOriginProtection,
and bearer requests do not. A request carrying an Authorization header
is judged on that header alone and never falls back to the cookie.
Changing a password ends every other session of that user. Changing
your own requires the current password, so a phone left signed in
cannot be used to take the account over.

Failed logins are counted per username and per client address. Ten
failures for one username in 15 minutes refuse that username for the
rest of the window, even with the right password. That makes locking
somebody out possible for anyone who knows their username. It was
accepted because the alternative is unlimited guessing, and during a
lockout the notification's Acknowledge button and API keys keep
working. The address limit reads the first X-Forwarded-For hop, since
behind the gateway RemoteAddr is Envoy. It is looser, because a whole
office behind one NAT shares it.

The Secure flag follows TERDUT_PUBLIC_URL, since TLS terminates at the
gateway and the server itself only ever sees plain HTTP. The chart
already defaults that variable to https://<hostname>.

Schedule editing, statistics and user management stay in terdut-tui for
now. The API they use is unchanged, and bearer authentication behaves
exactly as before.
2026-09-19 17:48:21 +02:00
Niklas Ye 9669b8f477 Set the chart's placeholder version to 0.9.4
CI / chart (push) Successful in 0s
CI / security (push) Successful in 24s
CI / test (push) Successful in 28s
Release / test (push) Successful in 28s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m11s
Release / scan-image (push) Successful in 23s
Cosmetic, as in f46e5f5. `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.9.4 that still says 0.9.3 tells
a reader something false, and the tree is what gets read before the tag
exists. appVersion keeps the v, per APPVERSION_PREFIX in .release.conf.

Claude-Session: https://claude.ai/code/session_014m2pJdpCTv3mvvUUuBM54Y
2026-09-04 18:08:21 +02:00
Niklas Ye a7871ed7c6 Stop the bootstrap hook installing curl at run time
The hook's container was alpine:3 and its first line was
`apk add --no-cache curl`. That writes the binary into the container's
writable upper layer, and every exec of it afterwards is, correctly, a
dropped binary: Falco's `Drop and execute new binary in container`
(PCI_DSS_11.5.1, MITRE TA0003) fired twice at Critical on the upgrade to
chart 0.9.3, 65ms after the container started, with
evt.arg.flags=EXE_WRITABLE|EXE_UPPER_LAYER. Ryuvia/charts#100 has the
event lines.

A true positive of the rule and a false positive of intent, and it is not
a one-off: the hook is post-install,post-upgrade, so it recurred on every
release. The cluster is still in the Falco burn-in with detections routed
to a null receiver, which is the only reason nobody was paged for it.

Fixed here rather than with a Falco exception on purpose. An exception
would have to name this container and would then stay in the rule set
forever, blinding it for the one workload that already runs as root with
create-secret RBAC, and it would leave the second problem untouched: this
runs as a post-upgrade hook, a failed hook fails the release, so every
`helm upgrade` of terdut-server depended on dl-cdn.alpinelinux.org
answering. That dependency is now gone.

alpine/curl is still a full Alpine, so sh, cat, sleep, grep, cut, head and
tail are all present -- verified in-cluster before the swap rather than
assumed, since a missing utility would surface as a failed post-upgrade
hook and not as anything visible here. Digest-pinned, as the wrapper
chart's own sidecar images are. The image declares an ENTRYPOINT, which
the Job's `command:` overrides; a comment says so, because rewriting that
to `args:` would silently run curl's entrypoint instead of the script.

No change to the script's logic, to the RBAC, or to when the hook runs.
Nothing on the terdut-tui side of the API moves, and no terdut-tui version
is required or excluded by this.

Worth recording while it is in view, and deliberately not acted on here:
there is no terdut-server-admin-key secret in the namespace, so the POST
returns 403, the hook logs "Server already bootstrapped, nothing to do"
and exits before the secret-creating branch. On an upgrade this hook
currently achieves nothing at all. Narrowing it to post-install would
remove the detection outright, but that changes what the hook is for and
belongs in its own change.

Claude-Session: https://claude.ai/code/session_014m2pJdpCTv3mvvUUuBM54Y
2026-09-04 18:08:05 +02:00
Niklas Ye 79f5db2636 Scan the source and the working tree too, not just the image
CI / chart (push) Successful in 0s
CI / security (push) Successful in 19s
CI / test (push) Successful in 25s
The image scan added yesterday reads the built artifact. It cannot see a
vulnerable dependency the binary never calls into, and it cannot see a
credential in a file that never reaches the image — this one is FROM scratch
and contains a single binary, so almost nothing in the repo is in it. Those
are two different questions and they need two different tools, which is why
riksdata and rd-web have run govulncheck and gitleaks all along.

Both run on every push and pull request rather than only on a tag, since
neither needs anything published.

Checked by hand before wiring in, as with the image scan. govulncheck
reports no vulnerabilities the code can reach, and gitleaks finds nothing in
the tree.

What govulncheck does report is worth writing down, because it is the
argument for having it. It found three advisories in chi and reports none of
them, all three being IP spoofing in middleware.RealIP, which router.go does
not use — it uses Logger and Recoverer. The analysis is symbol-level rather
than dependency-level, so adding middleware.RealIP would turn this red on
the next push. That is precisely when someone should be made to look, and it
is a plausible thing to reach for here, since the API sits behind a gateway
and real client addresses are exactly what RealIP is for. The fourth finding
is an integer overflow in golang.org/x/sys/windows, which a linux/scratch
image will not be calling.

Both gates were checked for the failure direction as well. gitleaks exits 1
on a private key block. Worth knowing when testing it: it allowlists
well-known example credentials, so the AWS key from Amazon's own
documentation does not trip it and proves nothing.

Neither reads git history. gitleaks runs with --no-git, which scans the
working tree, so it stops a secret on the way in and says nothing about what
is already committed.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-02 10:11:13 +02:00
Niklas Ye 84146fc903 Scan the published image for known vulnerabilities
CI / chart (push) Successful in 1s
CI / test (push) Successful in 25s
terdut-server was the only one of the three release-managed repos with no
image scanning at all. riksdata and rd-web have had a scan-image job since
they were set up; everything published here up to and including v0.9.3 went
out without a CVE check.

It scans the pushed image rather than a locally built one, for the same
reason the siblings do: trivy cannot read a local image on this runner,
since Talos has no docker socket and the dind sidecar shares no filesystem
with the job. So it runs after image rather than gating it, and a red scan
unpublishes nothing. What it means is narrower and worth stating plainly: do
not bump the wrapper chart in Ryuvia/charts to that version.

Checked before wiring it in rather than after. v0.9.3 scans clean at
HIGH,CRITICAL with unfixed findings ignored, so this does not turn the
pipeline red on arrival, and the same command exits 1 on an image that does
have findings — a gate that cannot fail is not a gate.

One platform is scanned, not both. The image is FROM scratch, so there are
no OS packages and trivy sees a single target: the Go binary and its module
graph. linux/amd64 and linux/arm64 are that same module set built for a
different GOARCH, so a finding in one is a finding in both. On an image with
a base layer that reasoning would not hold.

Still no govulncheck and no gitleaks here, which riksdata and rd-web run in
a separate CI job. This is the only security scanning terdut-server has.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-02 10:04:51 +02:00
Niklas Ye c6f1fe317e Correct the docs that said this repo has no publishing targets
CI / chart (push) Successful in 0s
CI / test (push) Successful in 25s
Both CLAUDE.md and README.md claimed there were deliberately no build or
push targets because the workflow owned publishing. That stopped being true
in 69fcc24, which moved publishing onto the Makefile so release.yaml could
call it — the docs described the arrangement that change replaced.

The claim was wrong in its reasoning too, not just out of date. It was
written on the assumption that riksdata and rd-web duplicated their
pipelines by having those targets. They never did: their workflows call
make and always have, which is what makes a green gate locally and a green
pipeline the same code instead of two descriptions of it. This repo was the
exception, for the single day it had a Makefile that nothing called.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:44:22 +02:00
Niklas Ye f46e5f5729 Set the chart's placeholder version to 0.9.3
CI / chart (push) Successful in 0s
CI / test (push) Successful in 24s
Release / test (push) Successful in 24s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 9s
Release / image (push) Successful in 57s
Cosmetic, and done anyway, for the same reason as 477454e: these two lines
decide nothing about what is published — `helm package --version
--app-version` now sets both from the tag, so they are not even read during
a release — but a tree heading for v0.9.3 that says 0.9.2 tells its reader
something false.

The comment above them was rewritten to stop naming a specific version. It
described the tree as "heading for v0.9.2", which is exactly the kind of
line that has to be edited every release and is therefore the kind that goes
stale. It also said nothing that publishes looks at these fields, which only
became literally true in 69fcc24 when the sed was replaced by helm's own
flags.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:38:41 +02:00
Niklas Ye 69fcc24a4d Drive the pipeline through make, the way riksdata and rd-web do
CI / chart (push) Successful in 0s
CI / test (push) Successful in 57s
Both workflows restated the build in YAML: gofmt, go vet and go test inline
in two places, buildx inline in a third, and the chart's version sed'd into
Chart.yaml before packaging. The Makefile added in 1081260 then described
the same checks a second time for local use, which made "green locally means
green in CI" a promise about keeping two files in step rather than a
property of the setup.

riksdata and rd-web never had that problem — their workflows call make and
have done all along. This repo was the odd one out, and only because it had
no Makefile until today. Now ci.yaml runs `make fmt lint test`, release.yaml
runs the same plus `make binaries`, `make push`, `make helm-package` and
`make helm-push`, and the reasoning behind each check lives on the target
rather than in whichever YAML file was edited last.

Three things change rather than just move:

The chart is linted before it is published. release.yaml packaged and pushed
without ever rendering the templates, so a chart that did not compile would
have reached the registry and been found by Flux. ci.yaml gained a chart job
for the same reason.

helm package --version --app-version replaces the sed. The published
metadata is identical, but the tree is no longer mutated mid-build, and it
is what the rest of the release process already assumed happened.

`make push` refuses VERSION=dev. Publishing is one command now, so it is
also one command to run by accident; dev is not a version anyone releases.

Deliberately not moved: uploading the release assets. Compiling them is
`make binaries` and runs anywhere, but the upload needs a token and the
Gitea release API, which is the workflow's business and not something worth
a target.

`push` builds and pushes in one step, unlike riksdata's separate build and
push, because buildx cannot load a multi-platform image into the local store
— it can only push it. `build` stays single-platform and local-only.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:36:54 +02:00
Niklas Ye 6a4f902e38 Declare that this repo's release prose is English
CI / test (push) Successful in 5s
The release skill's house style opened with "everything here is written in
Swedish", stated as a house default. It is not one: Swedish belongs to
riksdata and rd-web because their interface copy and the riksdag data they
present are inseparable from it. Nothing about an on-call tool is — the
labels, the API and the data here are English, and so are the code comments
and the docs.

Stated as a rule that was remembered rather than read, it produced the whole
of v0.9.2 in Swedish: four commits, the tag body and both charts PRs. The
PRs were rewritten; the commits on main and the published tag could not be,
so v0.9.2 stays as the record of the mistake.

PROSE_LANG makes it a declaration each repo carries, which release-preflight
prints on the prose line before any of the four texts get written. See
reference/house-style.md in the skill.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:24:41 +02:00
Niklas Ye 5f9c202d65 Beskriv hur ett släpp går till, och vad som är särskilt här
CI / test (push) Successful in 6s
Repot kom in under den gemensamma släppprocessen i 1081260 utan att någon
dokumentation sa det. README hade Kubernetes och Development men ingenting om
vägen från en commit till något som rullar i klustret, och till skillnad från
riksdata och rd-web fanns ingen CLAUDE.md alls.

Två saker om just det här repot är värda att stå skrivna, eftersom båda
avviker från syskonrepona och båda upptäcktes mitt i ett släpp:

Pipelinen har ingen bildskanning. riksdata och rd-web kör trivy efter
publiceringen; .gitea/workflows/release.yaml här har test, binaries, image och
chart och inget mer. Ett grönt släpp är alltså inget belägg för att bilden är
fri från kända sårbarheter, och en släppnot får inte antyda det.

Wrapperchartets values.yaml har två tag:-rader, appbilden och
python-sidovagnen för säkerhetskopiering, så chart-bump behöver --image för
att veta vilken som flyttas. Utan den vägrar den, vilket är rätt.

Samtidigt: varför det inte finns några build- eller push-mål, varför chartet
publiceras enbart från taggen (766f439), och varför make test kör -race när CI
inte gör det. Helm-exemplet pekade fortfarande på 0.9.0 och går till 0.9.2.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:09:15 +02:00
68 changed files with 4888 additions and 820 deletions
+85 -24
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:
@@ -59,32 +82,70 @@ jobs:
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
# The gate is the Makefile's rather than a second copy of it here, the way riksdata
# and rd-web already do it. `make fmt lint test` is exactly what a developer runs, so
# a green pipeline and a green working copy mean the same thing by construction
# instead of by remembering to update two files together.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
# The reasoning that used to live here moved with the targets: why gofmt is checked
# at all (import order survives `go vet`, and both repos sat unformatted through a
# green run and a release -- 9046f6e), why both of gofmt's failure modes need
# handling, and why `test` adds -race when this job does not have to.
- name: Format, vet and test
run: make fmt lint test
# Runs on every push and pull request, unlike the image scan, which needs something
# published to scan and so lives in release.yaml. Both are needed: govulncheck reads the
# source and its module graph, trivy reads the built artifact, and neither sees what the
# other does.
security:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
if [ -n "$HEAD_SHA" ]; then
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
- name: Vet
run: go vet ./...
- name: Go vulnerability scan (govulncheck)
run: make security-go
- name: Test
run: go test ./...
- name: Secret scan (gitleaks)
run: make security-secrets
# Host mode, no `container:`: helm is baked into the runner image, and a container job
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
# release.yaml's chart job runs on the host.
#
# The chart had no lint step in any workflow until 2026-09-01: release.yaml packaged and
# pushed it without rendering it first, so a template that did not compile would have
# been found by Flux rather than here.
chart:
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
- name: Lint and render the chart
run: make helm-lint
+79 -70
View File
@@ -35,41 +35,37 @@ 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:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Same gate as ci.yaml, and the same one a developer runs. See the Makefile for why
# each check is there; restating it here is how the two drift apart.
- name: Format, vet and test
run: make fmt lint test
binaries:
needs: test
@@ -86,21 +82,13 @@ jobs:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# Compiling is the Makefile's; uploading is not. `make binaries` is runnable on a
# laptop, while the step below needs a token and the Gitea release API, which is
# this workflow's business and nothing a developer wants a target for.
- name: Build every target
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
mkdir -p dist
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
out="dist/terdut-${REF_NAME}-${GOOS}-${GOARCH}"
echo "building $out"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags "-w -s -X main.version=${REF_NAME}" \
-o "$out" ./cmd/terdut
done
run: make binaries VERSION="$REF_NAME"
# Creating the release is made idempotent rather than assumed-new: a re-run of a
# failed release must not die on the release that already exists. Assets are
@@ -158,23 +146,12 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin
# The default "docker" driver cannot build more than one platform at a time; the
# docker-container driver can. Reused across runs if it survived the last one.
- name: Prepare buildx
run: docker buildx create --name terdut --use 2>/dev/null || docker buildx use terdut
# No QEMU: the Dockerfile's builder stage runs on $BUILDPLATFORM and cross-compiles
# from TARGETARCH, so both platforms build natively. See the comment in Dockerfile.
# buildx setup, the platform list and why there is no QEMU all live on the `push`
# target now, so the same command publishes from a laptop and from here.
- name: Build and push
env:
REF_NAME: ${{ github.ref_name }}
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "VERSION=${REF_NAME}" \
--tag "${IMAGE}:latest" \
--tag "${IMAGE}:${REF_NAME}" \
--push .
run: make push VERSION="$REF_NAME"
# Also host mode: helm is baked into the runner image, and a `container:` job could not
# install it -- get.helm.sh is unreachable from the dind bridge.
@@ -200,10 +177,10 @@ jobs:
# tagged with. One publisher, triggered by the tag.
#
# The cost is that the chart only ships with an app release. That is no real loss --
# the sed below ties the chart version to the app version, so a chart-only change
# `make helm-package` derives the chart version from the tag, so a chart-only change
# has no version of its own to be released under anyway. Chart fixes ride the next
# tag.
- name: Stamp the chart version from the tag
- name: Refuse a non-version tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
@@ -212,11 +189,17 @@ jobs:
echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}"
exit 1
fi
CHART_VERSION="${REF_NAME#v}"
sed -i "s/^version:.*/version: ${CHART_VERSION}/" charts/terdut-server/Chart.yaml
sed -i "s/^appVersion:.*/appVersion: \"${REF_NAME}\"/" charts/terdut-server/Chart.yaml
cat charts/terdut-server/Chart.yaml
# Render before publishing. Until 2026-09-01 this job packaged and pushed without
# linting, so a template that did not compile reached the registry and was found by
# Flux instead.
- name: Lint and render the chart
run: make helm-lint
# The version and appVersion are no longer sed'd into Chart.yaml before packaging:
# `helm package --version --app-version` sets both from the tag without mutating the
# tree mid-build, which is what the rest of the release process already assumed
# happened. The isolated helm repo list moved onto the targets with them.
- name: Package and push
env:
REF_NAME: ${{ github.ref_name }}
@@ -224,12 +207,38 @@ jobs:
run: |
set -eu
echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin
# Isolated repo config: the machine-wide helm repo list is not this job's
# business, and one unreachable entry in it aborts otherwise-fine commands.
# HELM_REPOSITORY_CACHE is deliberately NOT overridden alongside it -- helm
# writes a refreshed index to the default cache and then looks for it in the
# overridden one.
export HELM_REPOSITORY_CONFIG="$PWD/.helm-repos.yaml"
: > "$HELM_REPOSITORY_CONFIG"
helm package charts/terdut-server -d dist
helm push "dist/terdut-server-${REF_NAME#v}.tgz" "oci://${REGISTRY}/niklas"
make helm-package helm-push VERSION="$REF_NAME"
# Host mode, like image and chart: this needs a docker daemon to run trivy in, and a
# `container:` job would sit on the dind bridge with none.
#
# It scans the pushed image rather than a locally built one, because trivy cannot read a
# local image on this runner -- Talos has no docker socket and the dind sidecar shares no
# filesystem with the job -- so it pulls from the registry. That is also why this runs
# after `image` rather than gating it: a red scan does not unpublish anything.
#
# What a red scan means is therefore not "the release failed" but "do not bump the wrapper
# chart in Ryuvia/charts to this version". The image and chart are already published by
# the time this runs, and deliberately so -- this pipeline does not deploy.
#
# riksdata and rd-web have had this since they were set up; terdut-server went without any
# image scanning until 2026-09-02, so every release before v0.9.4 was published with no
# CVE check at all.
scan-image:
needs: image
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# Credentials are passed even though these packages are anonymously pullable -- that
# is a property of the personal namespace this publishes to, not something a release
# should depend on staying true.
- name: Scan the pushed image (trivy)
env:
TRIVY_USERNAME: niklas
TRIVY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
REF_NAME: ${{ github.ref_name }}
run: make security-image VERSION="$REF_NAME"
+5
View File
@@ -1,6 +1,11 @@
# build output
/terdut
/terdut-server
# `make binaries` and `make helm-package` write here
/dist/
# isolated helm repo list written by the publishing targets, so the machine-wide
# one (which has an unreachable entry) cannot abort a release
/.helm-repos.yaml
# SQLite database files
*.db
+7
View File
@@ -6,6 +6,7 @@
# Defaults, set here only where this repo differs:
# CHARTS_REPO=$HOME/git/charts CHARTS_DIR=<image basename>
# GITEA_LOGIN=Ryuvia APPVERSION_PREFIX=
# PROSE_LANG=en
# Same as the image basename, so this is only stated to be read rather than derived.
CHARTS_DIR=terdut-server
@@ -15,3 +16,9 @@ CHARTS_DIR=terdut-server
# version and appVersion from the tag when it publishes -- but people read it, and until
# 2026-09-01 it said "latest" while the tree headed for a numbered release.
APPVERSION_PREFIX=v
# English. The Swedish in riksdata and rd-web follows from their subject matter, not from a
# house style: terdut-server is an on-call tool whose labels, API and data are English, and
# nothing about it is coupled to Swedish. Code comments and docs here were always English;
# from 2026-09-01 the release prose is too.
PROSE_LANG=en
+49
View File
@@ -0,0 +1,49 @@
## Release
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it: commit, push, tag,
wait for the pipeline, then open the wrapper-chart PR against `Ryuvia/charts`. It stops
there — merging and the Flux reconcile stay manual, deliberately.
Preconditions and the plan, without side effects:
```sh
~/.claude/skills/release/scripts/release-preflight # state + suggested version
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
```
Config is `.release.conf` here plus `make release-vars`. The process itself lives in
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
Two things about this repo specifically:
- **The image is scanned after it is published, not before.** `scan-image` runs trivy
against the pushed image, because trivy cannot read a locally built one on this runner.
A red scan therefore unpublishes nothing — it means: do not bump the wrapper chart in
`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. 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`,
which the workflows do not have to ask for since they call the target; see the comment on it
for why.
`make release` (build + push the multi-arch image, package + push the chart) is what
`release.yaml` invokes. Do not run it by hand — it refuses `VERSION=dev` for that reason, and
publishing happens by pushing a tag.
Three scans, and they see different things: `security-go` (govulncheck) reads the source and
its module graph and reports only vulnerabilities the code can actually reach;
`security-secrets` (gitleaks) reads the working tree, not the history, so it catches a secret
on the way in rather than auditing what is already committed; `security-image` (trivy) reads
the published artifact and therefore only runs on a tag. The first two gate every push.
+180 -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 ---
@@ -82,3 +122,138 @@ release-vars: ## Print the variables the release process reads
# the multi-arch tag, which is both easy to do by accident and invisible afterwards
# — the tag would still resolve, just not on arm64. Publishing happens by pushing a
# tag; nothing else.
## --- publishing ---
#
# These exist so .gitea/workflows/release.yaml can call `make release` instead of
# restating the build in YAML, the way riksdata and rd-web already do. One definition
# of how this is built and published, runnable locally, reviewable in a diff.
#
# VERSION is the git tag, passed in by the workflow. The guard below is why a stray
# local `make release` cannot publish: dev is not a version anyone releases.
VERSION ?= dev
# Helm requires strict SemVer — strip a leading 'v' if present.
CHART_VERSION := $(shell echo "$(VERSION)" | sed 's/^v//')
PLATFORMS ?= linux/amd64,linux/arm64
BUILDX_BUILDER ?= terdut
TRIVY_VERSION := 0.73.0
GOVULNCHECK_VERSION := v1.1.4
GITLEAKS_VERSION := v8.30.0
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
DOCKER_BUILD_FLAGS ?= --pull
# An isolated repo list. The machine-wide one is not this build's business, and one
# unreachable entry in it aborts otherwise-fine helm commands — there is a dead
# TrueCharts repo on this host that does exactly that. HELM_REPOSITORY_CACHE is
# deliberately NOT overridden alongside it: helm writes a refreshed index to the default
# cache and then looks for it in the overridden one.
HELM_ISOLATED = HELM_REPOSITORY_CONFIG=$(CURDIR)/.helm-repos.yaml
.PHONY: require-version
require-version:
@test "$(VERSION)" != "dev" || \
(echo "VERSION=dev names no release — pass VERSION=vX.Y.Z (the workflow passes the tag)" && exit 1)
.PHONY: build
build: ## Build the image for this host only, without pushing (local check / CI smoke)
docker build $(DOCKER_BUILD_FLAGS) \
--build-arg VERSION=$(VERSION) \
-t $(IMAGE):$(VERSION) .
# Multi-arch, so unlike riksdata and rd-web this cannot be a separate build then push:
# buildx cannot load a multi-platform result into the local image store, it can only
# push it. `build` above stays single-platform and local-only for that reason.
#
# No QEMU: the Dockerfile's builder stage runs on $$BUILDPLATFORM and cross-compiles from
# TARGETARCH, so both platforms build natively. The default "docker" driver cannot build
# more than one platform at a time; the docker-container driver can.
.PHONY: push
push: require-version ## Build and publish the multi-arch image
docker buildx create --name $(BUILDX_BUILDER) --use 2>/dev/null || docker buildx use $(BUILDX_BUILDER)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg "VERSION=$(VERSION)" \
--tag "$(IMAGE):latest" \
--tag "$(IMAGE):$(VERSION)" \
--push .
# --version and --app-version come from the tag, so Chart.yaml's own fields decide nothing
# about what is published. They used to be rewritten in place with sed before packaging;
# the flags do the same job without mutating the tree mid-build.
.PHONY: helm-package
helm-package: require-version ## Package the chart, versioned from the tag
$(HELM_ISOLATED) helm package $(HELM_CHART) \
--version $(CHART_VERSION) \
--app-version $(VERSION) \
--destination dist
.PHONY: helm-push
helm-push: require-version ## Push the packaged chart to the OCI registry
$(HELM_ISOLATED) helm push dist/terdut-server-$(CHART_VERSION).tgz $(HELM_REPO)
.PHONY: binaries
binaries: require-version ## Cross-compile the release binaries into dist/
@mkdir -p dist
@set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
GOOS="$${target%/*}"; GOARCH="$${target#*/}"; \
out="dist/terdut-$(VERSION)-$${GOOS}-$${GOARCH}"; \
echo "building $$out"; \
GOOS="$$GOOS" GOARCH="$$GOARCH" go build \
-ldflags "-w -s -X main.version=$(VERSION)" \
-o "$$out" ./cmd/terdut; \
done
.PHONY: release
release: push helm-package helm-push ## Publish image + chart (the workflow's one call)
## --- security ---
# Symbol-level, not dependency-level: govulncheck reports a vulnerability only when the
# code can actually reach it. As of 2026-09-02 this repo imports three chi advisories and
# reports none of them, because all three are middleware.RealIP and router.go uses Logger
# and Recoverer. That is the useful property rather than a loophole -- adding
# middleware.RealIP would turn this red, which is exactly when someone should look.
.PHONY: security-go
security-go: ## Scan Go deps for known CVEs (govulncheck)
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
# --no-git scans the working tree rather than the history, so this catches a secret on the
# way in. It is not a history audit and finding nothing here says nothing about what is
# already committed. --redact because the finding is printed into a CI log.
#
# Note when testing it that gitleaks allowlists well-known example credentials -- the AWS
# key from their own documentation does not trip it. A private key block does.
.PHONY: security-secrets
security-secrets: ## Scan the working tree for committed secrets (gitleaks)
go run github.com/zricethezav/gitleaks/v8@$(GITLEAKS_VERSION) detect --no-git \
--source . --redact --no-banner --exit-code 1
# Scans the pushed image, not a local one: trivy cannot read a locally built image on the
# runner -- Talos has no docker socket, and the dind sidecar shares no filesystem with the
# job -- so it pulls from the registry. Same reason riksdata and rd-web scan after pushing.
#
# It cannot gate a deploy, because this pipeline does not deploy. A red scan means: do not
# bump the wrapper chart in Ryuvia/charts to this version.
#
# The image is FROM scratch, so there are no OS packages to scan and trivy sees exactly one
# target -- the Go binary and its module graph. That also makes scanning a single platform
# sufficient here: linux/amd64 and linux/arm64 are the same modules built for a different
# GOARCH, so a CVE in one is a CVE in both. On an image with a base layer that would not
# hold and both platforms would need scanning.
#
# This is the last of the three scans and the only one that needs a published artifact;
# security-go and security-secrets above run on every push.
.PHONY: security-image
security-image: require-version ## Scan the pushed image for CVEs (needs VERSION)
@# The named volume persists trivy's vulnerability DB between runs; without it every
@# scan re-downloads the whole database.
docker run --rm -e TRIVY_USERNAME -e TRIVY_PASSWORD \
-v trivy-cache:/root/.cache/trivy \
docker.io/aquasec/trivy:$(TRIVY_VERSION) image --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 $(IMAGE):$(VERSION)
+196 -23
View File
@@ -7,8 +7,9 @@ Incident management server for teams using Prometheus Alertmanager.
- Incident workflow: acknowledge, assign, snooze, note, resolve, with a full timeline
- On-call schedule management, with new incidents auto-assigned to whoever is on call
- 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
---
@@ -29,10 +30,11 @@ The server starts on `:8080` with a `terdut.db` file in the working directory.
```bash
curl -X POST http://localhost:8080/api/bootstrap \
-H "Content-Type: application/json" \
-d '{"username": "admin", "email": "admin@example.com"}'
-d '{"username": "admin", "email": "admin@example.com", "password": "<at least 10 characters>"}'
```
Save the `api_key.key` value from the response — it is shown **once only**.
Save the `api_key.key` value from the response — it is shown **once only**. The
`password` is optional and is what signs you in to the [web UI](#web-ui).
Use it as a bearer token for all subsequent requests:
@@ -41,15 +43,60 @@ export KEY=<your-key>
curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
```
### Web UI
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.
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:
```bash
# an admin sets someone's first password with their API key
curl -X PUT http://localhost:8080/api/users/2/password \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"password": "<at least 10 characters>"}'
```
After that, users change it themselves under *Account*. Changing your own
password requires the current one.
How a browser stays signed in:
- A successful login sets an `HttpOnly`, `SameSite=Lax` session cookie. It lasts
30 days and slides forward while it is used, so an on-call phone stays signed
in.
- The cookie is marked `Secure` when `TERDUT_PUBLIC_URL` starts with `https://`,
so set it to the HTTPS address. TLS terminates at the gateway and the server
itself only ever sees plain HTTP.
- Requests authenticated by the cookie are checked for cross-origin use (Go's
`http.CrossOriginProtection`). That is the CSRF guard. Bearer-key clients are
not affected.
- Setting a password signs that user out everywhere else.
- Ten failed logins for one username within 15 minutes lock that username for
the rest of the window.
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
the web UI (`/incidents/{id}`).
### 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
@@ -57,7 +104,7 @@ with the app — chart `x.y.z` is always app `vx.y.z`:
```bash
helm upgrade --install terdut-server oci://git.ryuvia.com/niklas/terdut-server \
--version 0.9.0 \
--version 0.9.2 \
--namespace terdut-server --create-namespace \
--set networking.hostname=terdut.example.com
```
@@ -72,24 +119,37 @@ 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.
---
@@ -98,7 +158,7 @@ Set `backupSidecar.enabled=false` if you back the volume up some other way.
| Variable | Default | Description |
|---|---|---|
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
| `TERDUT_DB_PATH` | `terdut.db` | Path to the SQLite database file |
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | How long a resolved alert or incident stays in the default list before being auto-archived |
| `TERDUT_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` |
@@ -107,7 +167,7 @@ Set `backupSidecar.enabled=false` if you back the volume up some other way.
| `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, for the link and Acknowledge button inside a notification |
| `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 |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -375,21 +435,32 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### Authentication
All endpoints except `/api/bootstrap` and `/api/alertmanager/webhook` require:
All endpoints except `/api/bootstrap`, `/api/alertmanager/webhook`,
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
```
Authorization: Bearer <api-key>
```
or the web UI's session cookie. A request that carries an `Authorization` header
is judged on that header alone.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
| `POST` | `/api/logout` | Ends the session and clears the cookie |
| `GET` | `/api/me` | The caller: `{user, has_password}` |
### Users
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/bootstrap` | Create first user + API key (only works on empty DB) |
| `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 |
@@ -607,6 +678,33 @@ averages over incidents that have actually been acknowledged or resolved, and ar
---
## Upgrading from SQLite
Versions up to v0.10.2 stored everything in a SQLite file. From the Postgres release onwards
the server needs `TERDUT_DB_DSN` and keeps nothing on disk.
The cutover is ordered — the server must not be running while the copy happens:
```bash
# 1. Stop the old server, keeping its database file.
# 2. Create an empty Postgres database, then let the new binary build the schema:
TERDUT_DB_DSN='postgres://terdut:secret@localhost:5432/terdut?sslmode=disable' ./terdut &
# ...watch for "listening on", then stop it again.
# 3. Copy the data across:
go run -tags migrate ./scripts/sqlite-to-postgres.go \
-sqlite /data/terdut.db \
-dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
# 4. Start the new server for good.
```
The 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. On Kubernetes, step 3 runs as a Job
with the same image against the PVC before it is removed.
The script is deliberately temporary: it is the only thing left that needs the SQLite driver,
and both should be deleted once the installs that need them have migrated.
## Upgrading to incidents
The incidents release moves the workflow off alerts, which is a **breaking API
@@ -657,7 +755,82 @@ 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 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,
embedded into the binary with `go:embed`. It has no build step and no npm, so
editing a file and restarting the server is the whole loop.
## Releasing
```
push or PR → ci.yaml gofmt, go vet, go test -race
govulncheck, gitleaks
helm lint + render
push tag vX.Y.Z → release.yaml the same gate, then publish:
git.ryuvia.com/niklas/terdut-server:vX.Y.Z
oci://git.ryuvia.com/niklas/terdut-server X.Y.Z
then trivy-scan the pushed image
PR to Ryuvia/charts → bump the wrapper chart to X.Y.Z; on merge
Flux reconciles and the release rolls out
```
Both artifacts go to the **personal** Gitea namespace rather than `ryuvia`, because Gitea
scopes package visibility to the owner with no per-package override — so `ryuvia/*` is private
because the org is. Publishing to `niklas` keeps them anonymously pullable, which is why no
pull secret is needed in the cluster. Same reasoning, and the same choice, as riksdata and
rd-web.
Saying **"Release"** runs all three rows: the `release` skill commits, pushes, tags, waits for
the pipeline, and opens the `Ryuvia/charts` PR, stopping before the merge. See
`~/.claude/skills/release/`, or `.release.conf` here for this repo's part of it.
The chart is published **only** from the tag, by the `chart` job. There used to be a second
publisher on every `charts/**` push to main, and the two raced for the same chart version with
different answers — chart 0.9.0 went out reading `appVersion: "latest"` that way. One
publisher, triggered by the tag (`766f439`). The cost is that a chart-only change has no
version of its own and rides the next app tag.
Both workflows are thin drivers over the Makefile: `ci.yaml` runs `make fmt lint test` and
`make helm-lint`, `release.yaml` adds `make binaries`, `make push`, `make helm-package` and
`make helm-push`. That is deliberate — it is what makes a green local gate and a green
pipeline the same code rather than two descriptions of it, and it is how riksdata and rd-web
have always worked.
`make push` builds and pushes in one step, unlike those two, because the image is
`linux/amd64,linux/arm64` and buildx cannot load a multi-platform result into the local image
store. `make build` stays single-platform and local-only. Both refuse `VERSION=dev`:
publishing is one command, so it is also one command to run by accident. Publishing happens
by pushing a tag.
Two things the release process needs to know about this repo:
- **The image scan runs after publishing**, like riksdata's and rd-web's: trivy cannot read
a locally built image on this runner, so it pulls the pushed one. A red `scan-image` means
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. 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
new artifact and the change is never deployed — with no error anywhere.
+6 -5
View File
@@ -6,13 +6,14 @@ type: application
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
# publishes, so the chart version always equals the app version.
#
# They are kept in step with the tag anyway. Being read is the only thing these two
# lines do -- nothing that publishes looks at them -- and a tree heading for v0.9.2 that
# says 0.9.0 tells its reader something false. That is what they said until 2026-09-01,
# They are kept in step with the tag anyway. Being read is the only thing these two lines
# do -- `helm package --version --app-version` sets the published values from the tag and
# never consults these -- and a tree heading for a numbered release that states an older
# number tells its reader something false. They said 0.9.0 and "latest" until 2026-09-01,
# through two releases.
#
# 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.9.2
appVersion: "v0.9.2"
version: 0.11.1
appVersion: "v0.11.1"
@@ -23,13 +23,23 @@ spec:
serviceAccountName: {{ include "terdut-server.fullname" . }}-bootstrap
containers:
- name: bootstrap
image: alpine:3
# alpine/curl, not alpine:3 + `apk add curl`. Installing the binary at run time
# writes it into the container's writable upper layer, which is exactly the
# signature Falco's `Drop and execute new binary in container` (MITRE TA0003)
# exists to catch -- this hook emitted two Critical events on every single
# upgrade. See Ryuvia/charts#100. It also made `helm upgrade` depend on the
# Alpine CDN answering, since this runs as a post-upgrade hook and a failed
# hook fails the release.
#
# Still a full Alpine underneath, so sh, cat, sleep, grep, cut, head and tail
# are all present (verified in-cluster 2026-09-04). The image declares
# ENTRYPOINT ["/entrypoint.sh"], which `command:` below overrides -- do not
# change `command:` to `args:`.
image: alpine/curl:8.21.0@sha256:a1c44bab54d88e18ea9a6a4ecefab7f2d230b968567b78960fcaff8d51b7f067
command:
- /bin/sh
- -c
- |
apk add --no-cache curl > /dev/null 2>&1
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
K8S_API="https://kubernetes.default.svc"
+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
+1 -1
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)
}
+9 -1
View File
@@ -4,16 +4,24 @@ 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/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
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.42.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+36 -8
View File
@@ -1,3 +1,6 @@
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
@@ -8,21 +11,46 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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=
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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.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=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/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=
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=
+12 -13
View File
@@ -171,7 +171,7 @@ 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 fingerprint = $1", a.Fingerprint,
).Scan(&prevStatus, &prevStartsAt); {
case err == sql.ErrNoRows:
existed = false
@@ -212,7 +212,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
INSERT INTO alerts
(fingerprint, name, status, labels, annotations, starts_at, ends_at,
generator_url, received_at, resolution_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10)
ON CONFLICT (fingerprint) DO UPDATE SET
status = excluded.status,
labels = excluded.labels,
@@ -245,7 +245,7 @@ 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 fingerprint = $1", a.Fingerprint,
).Scan(&id, &curStatus, &curStartsAt); err != nil {
return nil, err
}
@@ -315,7 +315,7 @@ 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 group_key = $1 AND resolved_at IS NULL", groupKey,
).Scan(&id); {
case err == nil:
return id, nil
@@ -349,15 +349,13 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
labelsJSON = []byte("{}")
}
res, err := q.ExecContext(ctx, `
var id int64
err = q.QueryRowContext(ctx, `
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at, assigned_to)
VALUES (?, ?, ?, 'triggered', ?, ?, ?)`,
VALUES ($1, $2, $3::jsonb, 'triggered', $4, $5, $6)
RETURNING id`,
groupKey, title, string(labelsJSON), severity,
time.Now().Unix(), onCall)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
time.Now().Unix(), onCall).Scan(&id)
if err != nil {
return 0, err
}
@@ -385,8 +383,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
}
+9 -15
View File
@@ -36,15 +36,13 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
q := r.URL.Query()
where := []string{}
args := []any{}
args := &sqlArgs{}
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 +51,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 +77,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
@@ -128,7 +122,7 @@ 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))
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = $1", id))
}
// scanner is satisfied by both *sql.Row and *sql.Rows.
+12 -19
View File
@@ -13,7 +13,6 @@ import (
"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
@@ -26,7 +25,7 @@ type ts struct {
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 {
@@ -46,15 +45,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)
}
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, deadman))
t.Cleanup(func() { srv.Close(); database.Close() })
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))
@@ -84,7 +77,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 +89,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 +102,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
@@ -556,7 +549,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 +646,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 +707,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 +761,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 +789,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
+18 -20
View File
@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"log"
"strings"
"time"
)
@@ -51,6 +51,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati
archiveResolved(ctx, db, archiveAfter)
archiveResolvedIncidents(ctx, db, archiveAfter)
purgeAckTokens(ctx, db)
purgeSessions(ctx, db)
}
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
@@ -90,17 +91,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
}
@@ -123,14 +125,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
@@ -205,10 +208,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
@@ -222,10 +225,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
@@ -234,8 +237,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), ", ")
}
+356
View File
@@ -0,0 +1,356 @@
package api
import (
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
const (
// sessionCookie carries a web UI session. It is HttpOnly, so page script
// never sees the token; the page learns who it is from GET /api/me.
sessionCookie = "terdut_session"
// sessionTTL is how long a session lives without being used. It slides, so
// a phone that opens the UI now and then stays signed in indefinitely.
sessionTTL = 30 * 24 * time.Hour
// sessionTouchEvery bounds how often a request may slide the expiry.
sessionTouchEvery = time.Hour
minPasswordLen = 10
// maxPasswordLen is bcrypt's limit; it rejects longer input outright.
maxPasswordLen = 72
loginWindow = 15 * time.Minute
loginMaxPerUser = 10
loginMaxPerAddr = 30
passwordHashCost = bcrypt.DefaultCost
)
// dummyHash is compared against when the username is unknown or has no
// password, so a failed login takes as long whichever way it failed.
var dummyHash = sync.OnceValue(func() []byte {
h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost)
return h
})
// loginLimiter counts failed logins in a fixed window, per username and per
// client address. The username limit is what stops guessing one account; the
// address limit is looser because every user behind the same gateway or NAT
// shares it.
type loginLimiter struct {
mu sync.Mutex
failures map[string]*loginWindowCount
}
type loginWindowCount struct {
start time.Time
n int
}
func newLoginLimiter() *loginLimiter {
return &loginLimiter{failures: map[string]*loginWindowCount{}}
}
func (l *loginLimiter) blocked(key string, max int) bool {
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.failures[key]
if !ok || time.Since(c.start) > loginWindow {
return false
}
return c.n >= max
}
func (l *loginLimiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for k, c := range l.failures {
if now.Sub(c.start) > loginWindow {
delete(l.failures, k)
}
}
for _, key := range keys {
c, ok := l.failures[key]
if !ok {
c = &loginWindowCount{start: now}
l.failures[key] = c
}
c.n++
}
}
func (l *loginLimiter) clear(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failures, key)
}
// clientAddr is the address a login is counted against. Behind the gateway
// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used
// when present. It can be forged, but only to dodge the address limit; the
// per-username limit does not depend on it.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway,
// so the server usually sees plain HTTP; the public URL is what says whether
// browsers reach it over HTTPS.
func cookieSecure(publicURL string, r *http.Request) bool {
return strings.HasPrefix(publicURL, "https://") ||
r.TLS != nil ||
r.Header.Get("X-Forwarded-Proto") == "https"
}
// validatePassword returns a message for the client, or "" when acceptable.
func validatePassword(pw string) string {
switch {
case len(pw) < minPasswordLen:
return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters"
case len(pw) > maxPasswordLen:
return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes"
}
return ""
}
func hashPassword(pw string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost)
return string(h), err
}
// 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) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
username := strings.TrimSpace(req.Username)
userKey := "user:" + strings.ToLower(username)
addrKey := "addr:" + clientAddr(r)
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
return
}
var userID int64
var hash sql.NullString
err := db.QueryRowContext(r.Context(),
"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"))
return
}
stored := dummyHash()
if hash.Valid {
stored = []byte(hash.String)
}
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
if !match || !hash.Valid {
limiter.fail(userKey, addrKey)
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
return
}
limiter.clear(userKey)
raw, tokenHash, err := randomToken()
if 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 ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: true})
}
}
// handleLogout ends the browser's session. It sits outside AuthMiddleware so
// that a browser holding an already-expired cookie can still clear it.
func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
crossOrigin := http.NewCrossOriginProtection()
return func(w http.ResponseWriter, r *http.Request) {
if err := crossOrigin.Check(r); err != nil {
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
return
}
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
}
type meResponse struct {
User any `json:"user"`
HasPassword bool `json:"has_password"`
}
// handleMe says who the caller is. The web UI calls it on load to decide
// between the login form and the app, since it cannot read its own cookie.
func handleMe(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
user, err := fetchUser(r.Context(), db, caller.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var hash sql.NullString
db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
}
}
// handleSetPassword sets a user's web UI password.
//
// 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.
//
// Every other session of the target is ended: a password change is what you
// do when you think someone else is signed in.
func handleSetPassword(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 {
Password string `json:"password"`
CurrentPassword string `json:"current_password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
var existing sql.NullString
err = db.QueryRowContext(r.Context(),
"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
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id && existing.Valid &&
bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil {
respond(w, http.StatusForbidden, errResp("current password is incorrect"))
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()
if _, err := tx.ExecContext(r.Context(),
"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 = $1 AND id != $2", id, keep); 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)
}
}
// 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 < $1", time.Now().Unix())
if err != nil {
log.Printf("sweeper: purge sessions: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("sweeper: purged %d expired session(s)", n)
}
}
+353
View File
@@ -0,0 +1,353 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
const adminPassword = "correct horse battery"
// browser is an HTTP client with its own cookie jar, standing in for one
// signed-in browser.
type browser struct {
*http.Client
base string
}
func newBrowser(t *testing.T, base string) *browser {
t.Helper()
jar, _ := cookiejar.New(nil)
return &browser{Client: &http.Client{Jar: jar}, base: base}
}
// do sends a request the way the web UI's own fetch would: same-origin, with
// the cookie from the jar.
func (b *browser) do(t *testing.T, method, path string, body any, header ...string) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, b.base+path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Sec-Fetch-Site", "same-origin")
for i := 0; i+1 < len(header); i += 2 {
req.Header.Set(header[i], header[i+1])
}
resp, err := b.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
func (b *browser) login(t *testing.T, username, password string) *http.Response {
t.Helper()
return b.do(t, http.MethodPost, "/api/login", map[string]string{"username": username, "password": password})
}
// setAdminPassword gives the bootstrapped admin a password over its API key.
func setAdminPassword(t *testing.T, s *ts) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/users/1/password", map[string]string{"password": adminPassword})
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set password: %d", resp.StatusCode)
}
}
func signedIn(t *testing.T, s *ts) *browser {
t.Helper()
setAdminPassword(t, s)
b := newBrowser(t, s.URL)
resp := b.login(t, "admin", adminPassword)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d", resp.StatusCode)
}
return b
}
func status(t *testing.T, resp *http.Response) int {
t.Helper()
resp.Body.Close()
return resp.StatusCode
}
func TestLogin_SetsSessionCookie(t *testing.T) {
s := newTS(t)
setAdminPassword(t, s)
b := newBrowser(t, s.URL)
resp := b.login(t, "admin", adminPassword)
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d", resp.StatusCode)
}
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "terdut_session" {
cookie = c
}
}
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("expected an HttpOnly, SameSite=Lax session cookie, got %+v", cookie)
}
if cookie.Secure {
t.Error("cookie is Secure on a plain-HTTP server with no https public URL")
}
var me struct {
User struct {
Username string `json:"username"`
} `json:"user"`
HasPassword bool `json:"has_password"`
}
decode(t, resp, &me)
if me.User.Username != "admin" || !me.HasPassword {
t.Errorf("unexpected login response %+v", me)
}
}
func TestLogin_CookieAuthenticatesAPI(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
if code := status(t, b.do(t, http.MethodGet, "/api/incidents", nil)); code != http.StatusOK {
t.Errorf("GET /api/incidents with cookie: %d", code)
}
resp := b.do(t, http.MethodGet, "/api/me", nil)
var me struct {
User struct {
ID int64 `json:"id"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.ID != 1 {
t.Errorf("/api/me returned user %d", me.User.ID)
}
}
func TestLogin_SecureCookieBehindHTTPSPublicURL(t *testing.T) {
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
setAdminPassword(t, s)
resp := newBrowser(t, s.URL).login(t, "admin", adminPassword)
resp.Body.Close()
for _, c := range resp.Cookies() {
if c.Name == "terdut_session" && !c.Secure {
t.Error("cookie should be Secure when the public URL is https")
}
}
}
func TestLogin_WrongPasswordAndUnknownUser(t *testing.T) {
s := newTS(t)
setAdminPassword(t, s)
b := newBrowser(t, s.URL)
if code := status(t, b.login(t, "admin", "not the password")); code != http.StatusUnauthorized {
t.Errorf("wrong password: %d", code)
}
if code := status(t, b.login(t, "nobody", adminPassword)); code != http.StatusUnauthorized {
t.Errorf("unknown user: %d", code)
}
}
func TestLogin_UserWithoutPasswordCannotSignIn(t *testing.T) {
s := newTS(t)
b := newBrowser(t, s.URL)
// The empty password must not match a user that has none.
if code := status(t, b.login(t, "admin", "")); code != http.StatusUnauthorized {
t.Errorf("login without a password set: %d", code)
}
}
func TestLogin_RateLimitedPerUsername(t *testing.T) {
s := newTS(t)
setAdminPassword(t, s)
b := newBrowser(t, s.URL)
for i := range 10 {
if code := status(t, b.login(t, "admin", "wrong")); code != http.StatusUnauthorized {
t.Fatalf("attempt %d: %d", i+1, code)
}
}
// Even the right password is refused once the limit is reached.
resp := b.login(t, "admin", adminPassword)
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d", resp.StatusCode)
}
if resp.Header.Get("Retry-After") == "" {
t.Error("429 without Retry-After")
}
resp.Body.Close()
}
func TestSession_CrossOriginWriteRejected(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
code := status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil,
"Sec-Fetch-Site", "cross-site", "Origin", "https://evil.example"))
if code != http.StatusForbidden {
t.Errorf("cross-origin POST with cookie: %d, want 403", code)
}
// The same request from the page itself gets through to the handler.
code = status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil))
if code != http.StatusNotFound {
t.Errorf("same-origin POST with cookie: %d, want 404 from the handler", code)
}
}
func TestSession_BearerIgnoresOriginChecks(t *testing.T) {
s := newTS(t)
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/incidents/999/acknowledge", nil)
req.Header.Set("Authorization", "Bearer "+s.key)
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if code := status(t, resp); code != http.StatusNotFound {
t.Errorf("Bearer request: %d, want 404 from the handler", code)
}
}
func TestLogout_EndsSession(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
if code := status(t, b.do(t, http.MethodPost, "/api/logout", nil)); code != http.StatusNoContent {
t.Fatalf("logout: %d", code)
}
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
t.Errorf("after logout: %d", code)
}
var n int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
if n != 0 {
t.Errorf("%d session(s) left after logout", n)
}
}
func TestSession_ExpiredIsRejected(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
s.exec(t, "UPDATE sessions SET expires_at = 1")
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{})
var n int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
if n != 0 {
t.Errorf("sweep left %d expired session(s)", n)
}
}
func TestSetPassword_OwnNeedsCurrent(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
code := status(t, b.do(t, http.MethodPut, "/api/users/1/password",
map[string]string{"password": "a brand new secret", "current_password": "wrong"}))
if code != http.StatusForbidden {
t.Errorf("wrong current password: %d", code)
}
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
map[string]string{"password": "short", "current_password": adminPassword}))
if code != http.StatusBadRequest {
t.Errorf("too-short password: %d", code)
}
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
if code != http.StatusNoContent {
t.Fatalf("change password: %d", code)
}
if code := status(t, newBrowser(t, s.URL).login(t, "admin", "a brand new secret")); code != http.StatusOK {
t.Errorf("login with the new password: %d", code)
}
}
func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
s := newTS(t)
phone := signedIn(t, s)
laptop := newBrowser(t, s.URL)
status(t, laptop.login(t, "admin", adminPassword))
code := status(t, phone.do(t, http.MethodPut, "/api/users/1/password",
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
if code != http.StatusNoContent {
t.Fatalf("change password: %d", code)
}
if code := status(t, phone.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusOK {
t.Errorf("the session that changed the password: %d", code)
}
if code := status(t, laptop.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
t.Errorf("the other session: %d", code)
}
}
func TestBootstrap_WithPassword(t *testing.T) {
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, api.DeadmanConfig{}))
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))
if err != nil {
t.Fatal(err)
}
if code := status(t, resp); code != http.StatusCreated {
t.Fatalf("bootstrap: %d", code)
}
if code := status(t, newBrowser(t, srv.URL).login(t, "admin", adminPassword)); code != http.StatusOK {
t.Errorf("login after bootstrap: %d", code)
}
}
func TestRouter_UnknownAPIPathIsJSON404(t *testing.T) {
s := newTS(t)
resp, err := http.Get(s.URL + "/api/nope")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
t.Errorf("GET /api/nope: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
}
func TestRouter_DeepLinkServesWebUI(t *testing.T) {
s := newTS(t)
resp, err := http.Get(s.URL + "/incidents/1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
t.Errorf("GET /incidents/1: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
if resp.Header.Get("Content-Security-Policy") == "" {
t.Error("web UI served without a CSP")
}
resp2, err := http.Get(s.URL + "/js/missing.js")
if err != nil {
t.Fatal(err)
}
if code := status(t, resp2); code != http.StatusNotFound {
t.Errorf("missing asset: %d", code)
}
}
+16 -14
View File
@@ -225,19 +225,21 @@ func sweepDeadman(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Not
// deadmanAlerts loads every alert row that a matcher 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.
// 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, cfg DeadmanConfig) ([]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
FROM alerts
WHERE name IN (`+placeholders(len(names))+`)
AND archived_at IS NULL`, args...)
WHERE name IN (`+args.addList(nameList)+`)
AND archived_at IS NULL`, args.all()...)
if err != nil {
return nil, err
}
@@ -277,8 +279,8 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
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 = ?`,
COUNT(*) FILTER (WHERE resolved_at IS NULL)
FROM incidents WHERE group_key = $1`,
sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
return err
}
@@ -299,9 +301,9 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
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, sw.id); err != nil {
return err
}
}
@@ -341,7 +343,7 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw 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 group_key = $1 AND resolved_at IS NULL`, sw.groupKey()).Scan(&incidentID); {
case err == sql.ErrNoRows:
return nil
case err != nil:
@@ -356,8 +358,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
}
+7 -4
View File
@@ -51,7 +51,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 +61,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 +75,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)
+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)
+14 -14
View File
@@ -95,7 +95,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 +103,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
}
@@ -118,7 +118,7 @@ func todayUTC() string {
func currentOnCall(ctx context.Context, q querier) (*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 date = $1", todayUTC()).Scan(&userID)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -154,10 +154,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 +185,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 +210,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)
@@ -245,8 +245,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
@@ -266,7 +266,7 @@ 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
}
+28 -35
View File
@@ -17,13 +17,12 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
q := r.URL.Query()
where := []string{}
args := []any{}
args := &sqlArgs{}
// 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 +35,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 +70,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
@@ -158,7 +150,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"))
@@ -214,7 +206,7 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
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 {
@@ -237,8 +229,8 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
}
user, _ := userFromContext(r.Context())
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
}
@@ -269,13 +261,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
}
@@ -333,7 +325,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
}
@@ -354,7 +346,7 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
}
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 {
@@ -372,7 +364,7 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
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
@@ -392,7 +384,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
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
@@ -430,14 +422,15 @@ 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, evNote, 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,
@@ -468,7 +461,7 @@ 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 = ?`,
WHERE id = $1 AND incident_id = $2 AND type = $3 AND user_id = $4`,
eventID, id, evNote, user.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -498,7 +491,7 @@ func incidentIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
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", id).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("incident not found"))
return false
}
@@ -538,7 +531,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
+5 -103
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)
@@ -611,7 +607,7 @@ 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)
@@ -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 {
+96 -16
View File
@@ -14,50 +14,130 @@ import (
type contextKey string
const ctxUser contextKey = "user"
const (
ctxUser contextKey = "user"
ctxSession contextKey = "session"
)
// AuthMiddleware accepts either of the two credentials the server issues: an
// API key in an Authorization header (the TUI, scripts) or a session cookie
// (the web UI). A request carrying a Bearer header is judged on that alone and
// never falls back to the cookie.
//
// Only the cookie needs a CSRF guard. A browser attaches it to requests other
// sites make, whereas an Authorization header is only ever set by the client
// that holds the key.
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
crossOrigin := http.NewCrossOriginProtection()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if header := r.Header.Get("Authorization"); header != "" {
token, ok := strings.CutPrefix(header, "Bearer ")
if !ok || token == "" {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
h := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(h[:])
var keyID, userID int64
err := db.QueryRowContext(r.Context(),
"SELECT id, user_id FROM api_keys WHERE key_hash = ?", hash,
).Scan(&keyID, &userID)
if err != nil {
userID, ok := apiKeyUser(r.Context(), db, token)
if !ok {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
serveAs(w, r, next, db, userID, 0)
return
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
sessionID, userID, ok := sessionUser(r.Context(), db, c.Value)
if !ok {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
if err := crossOrigin.Check(r); err != nil {
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
return
}
serveAs(w, r, next, db, userID, sessionID)
})
}
}
// 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 = $1", hashToken(token),
).Scan(&keyID, &userID)
if err != nil {
return 0, false
}
// best-effort; don't fail the request if this update fails
db.ExecContext(r.Context(),
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
db.ExecContext(ctx,
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
time.Now().Unix(), keyID)
return userID, true
}
// sessionUser resolves a session token to its session and user. The expiry
// slides forward with use, but at most once per sessionTouchEvery, so a page
// that polls does not write to the database on every request.
func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) {
now := time.Now()
var lastSeen int64
err := db.QueryRowContext(ctx, `
SELECT id, user_id, last_seen_at FROM sessions
WHERE token_hash = $1 AND expires_at > $2`,
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
if err != nil {
return 0, 0, false
}
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx,
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
}
return sessionID, userID, true
}
// serveAs loads the user and hands the request on with it in the context.
// sessionID is zero for API-key requests.
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
var u models.User
var createdUnix int64
if err := db.QueryRowContext(r.Context(),
"SELECT id, username, email, created_at FROM users WHERE id = ?", userID,
"SELECT id, username, email, created_at FROM users WHERE id = $1", userID,
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
})
ctx := context.WithValue(r.Context(), ctxUser, u)
if sessionID != 0 {
ctx = context.WithValue(ctx, ctxSession, sessionID)
}
next.ServeHTTP(w, r.WithContext(ctx))
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func userFromContext(ctx context.Context) (models.User, bool) {
u, ok := ctx.Value(ctxUser).(models.User)
return u, ok
}
// 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) {
id, ok := ctx.Value(ctxSession).(int64)
return id, ok
}
+17 -14
View File
@@ -151,19 +151,20 @@ 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 <= ?)`,
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)`,
now.Add(-cfg.RepeatEvery).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 +218,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 +241,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 +270,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,7 +308,7 @@ 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)
}
@@ -359,7 +360,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
msg := ntfyMessage{Topic: n.topic}
if cfg.PublicURL != "" {
msg.Click = fmt.Sprintf("%s/api/incidents/%d",
// The web UI's page for the incident, so tapping the notification
// opens something a browser can use.
msg.Click = fmt.Sprintf("%s/incidents/%d",
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
}
@@ -473,7 +476,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
}
@@ -487,7 +490,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
}
@@ -520,7 +523,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
}
+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
+8 -8
View File
@@ -125,7 +125,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 {
@@ -175,7 +175,7 @@ func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
if !strings.Contains(m.Message, "severity critical") {
t.Errorf("expected the severity in %q", m.Message)
}
if m.Click != "https://terdut.example.com/api/incidents/1" {
if m.Click != "https://terdut.example.com/incidents/1" {
t.Errorf("unexpected click target %q", m.Click)
}
if len(m.Actions) != 1 || m.Actions[0].Label != "Acknowledge" {
@@ -326,7 +326,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 +415,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,7 +436,7 @@ 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)
@@ -457,7 +457,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 +693,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 +715,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 {
+21
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"net/http"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
@@ -29,14 +30,21 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman))
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
// 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/logout", handleLogout(db, notify.PublicURL))
// All other /api routes require a valid API key.
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db))
r.Get("/api/users", handleListUsers(db))
r.Post("/api/users", handleCreateUser(db))
r.Delete("/api/users/{id}", handleDeleteUser(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))
@@ -72,5 +80,18 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
})
// Anything else under /api is a mistake in a client, and should say so in
// JSON rather than get the web UI's HTML.
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusNotFound, errResp("not found"))
}))
// Everything outside /api is the web UI.
site, err := web.Handler()
if err != nil {
panic(err) // the site is embedded at build time; this cannot fail at runtime
}
r.Handle("/*", site)
return r
}
+10 -12
View File
@@ -45,7 +45,7 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
// Verify the user exists.
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
@@ -64,14 +64,14 @@ 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 date = $1", 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 (user_id, date) VALUES ($1, $2)", req.UserID, d); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict,
errResp("date already assigned: "+d+" (pass replace to take it)"))
return
@@ -139,7 +139,7 @@ func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
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", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -162,7 +162,7 @@ func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
SELECT s.id, 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)
WHERE s.date = $1`, 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
@@ -180,14 +180,12 @@ func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
// 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{}
args := &sqlArgs{}
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"
@@ -200,7 +198,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S
FROM schedule_entries s
JOIN users u ON u.id = s.user_id
WHERE `+clause+`
ORDER BY s.date ASC`, args...)
ORDER BY s.date ASC`, args.all()...)
if err != nil {
return nil, err
}
+13 -15
View File
@@ -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"))
@@ -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
@@ -84,12 +82,12 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
where, args := statsFilter(r.URL.Query(), "received_at")
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
@@ -123,14 +121,15 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
// 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
@@ -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,17 @@ 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) {
func statsFilter(q url.Values, timeCol string) (where string, args *sqlArgs) {
args = &sqlArgs{}
clauses := []string{"archived_at IS NULL"}
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
+100
View File
@@ -0,0 +1,100 @@
package api_test
import (
"database/sql"
"fmt"
"net/url"
"os"
"strings"
"testing"
"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"
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 + "'"
}
+38 -22
View File
@@ -20,6 +20,9 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
// Password is optional; without one the first user can only use the
// API key until somebody sets it.
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
@@ -29,6 +32,19 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
var passwordHash *string
if req.Password != "" {
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
h, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
passwordHash = &h
}
var count int
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
@@ -40,26 +56,26 @@ func handleBootstrap(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 {
var userID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) 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()
user, _ := fetchUser(r.Context(), db, userID)
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
@@ -107,17 +123,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)
}
@@ -148,7 +164,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
@@ -174,7 +190,7 @@ 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)
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -209,7 +225,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
}
@@ -219,13 +235,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)
}
@@ -245,7 +261,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
@@ -276,7 +292,7 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = ?", id).
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic)
if err != nil {
return u, err
+9 -6
View File
@@ -7,7 +7,14 @@ import (
type Config struct {
Addr string
DBPath 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),
+46 -17
View File
@@ -7,24 +7,32 @@ import (
"io/fs"
"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)
// 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)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
@@ -32,10 +40,16 @@ func Open(path string) (*sql.DB, error) {
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 +69,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 +81,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;
@@ -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);
@@ -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);
-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);
@@ -1,2 +0,0 @@
ALTER TABLE alerts ADD COLUMN archived_at INTEGER;
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
@@ -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;
-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;
@@ -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;
+622
View File
@@ -0,0 +1,622 @@
/* terdut web UI.
*
* Mobile first: one column, a top bar and a bottom tab bar. From 900px the tab
* bar becomes a sidebar and the queue shows list and detail side by side.
* Colour is reserved for severity and status; everything else is neutral.
*/
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
--border: #e2e5ea;
--border-strong: #cfd3da;
--text: #16181d;
--muted: #5b626e;
--faint: #8a909b;
--accent: #2f5bd3;
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze-soft: #efedfb;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
--shadow-lg: 0 12px 32px rgb(16 24 40 / 18%);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--topbar-h: 52px;
--tabbar-h: 58px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a20;
--surface-2: #1f232b;
--surface-hover: #1c2027;
--border: #2a2f38;
--border-strong: #394050;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #737a87;
--accent: #6d8ff0;
--accent-text: #0b0d12;
--accent-soft: #1d2640;
--crit: #ff6b61;
--crit-soft: #3a1c1b;
--warn: #f0b140;
--warn-soft: #362a14;
--info: #74a3ff;
--info-soft: #1a2640;
--ok: #4cc488;
--ok-soft: #15301f;
--snooze: #a89bff;
--snooze-soft: #262245;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.45 var(--font);
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
code { font-family: var(--mono); font-size: 0.9em; }
h1, h2, h3 { margin: 0; line-height: 1.25; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------- boot + login ---------- */
.boot { display: grid; place-items: center; min-height: 100dvh; }
.spinner {
width: 26px; height: 26px; border-radius: 50%;
border: 3px solid var(--border); border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.login {
min-height: 100dvh;
display: grid; place-items: center;
padding: calc(24px + var(--safe-top)) 16px calc(24px + var(--safe-bottom));
}
.login-card {
width: 100%; max-width: 360px;
display: grid; gap: 14px;
}
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
label { display: grid; gap: 6px; }
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
input, textarea, select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 16px; /* 16px stops iOS zooming into the field */
}
textarea { min-height: 110px; resize: vertical; line-height: 1.45; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.form-error {
margin: 0; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; padding: 0 16px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600; font-size: 15px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover { background: var(--surface-hover); }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
.btn-danger:hover { background: var(--crit); filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); }
.btn-block { width: 100%; }
.btn-icon { width: 44px; padding: 0; }
.btn-sm { min-height: 32px; padding: 0 8px; font-size: 13px; }
.btn-sm svg { width: 16px; height: 16px; }
.btn svg, .icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ---------- app frame ---------- */
.app { min-height: 100dvh; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
height: calc(var(--topbar-h) + var(--safe-top));
padding: var(--safe-top) 16px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-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; }
.open-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px;
font-size: 13px; font-weight: 600;
background: var(--surface-2); color: var(--muted);
}
.open-pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* Bottom tab bar on phones. */
.nav {
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);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-link {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
}
.nav-link svg { width: 24px; height: 24px; 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);
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 999px; background: var(--crit); color: #fff;
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
}
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
/* Phone detail: the incident takes the whole screen with its own action bar,
so the tab bar and top bar step aside. */
.app.detail-open .nav,
.app.detail-open .topbar { display: none; }
.app.detail-open .pane-list { display: none; }
.app.detail-open .view-queue { padding-bottom: 0; }
.view-queue:not(.has-detail) .pane-detail { display: none; }
/* ---------- chips ---------- */
.chips {
display: flex; gap: 6px;
padding: 12px 16px 8px;
overflow-x: auto; scrollbar-width: none;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex: none;
min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; cursor: pointer;
}
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* ---------- lists ---------- */
.list { padding: 0 16px 16px; display: grid; gap: 8px; }
.row {
position: relative;
display: grid; grid-template-columns: 1fr auto; gap: 4px 12px;
padding: 11px 14px 11px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
cursor: pointer;
overflow: hidden;
}
.row:hover { background: var(--surface-hover); }
.row[aria-current="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.row.kbd-focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.row::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--sev, var(--border-strong));
}
.row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta {
grid-column: 1 / -1;
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
color: var(--muted); font-size: 13px;
min-width: 0;
}
.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); }
.row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); }
.sev-warning { --sev: var(--warn); }
.sev-info { --sev: var(--info); }
.empty {
padding: 48px 16px; text-align: center; color: var(--muted);
}
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
.load-error {
margin: 12px 16px; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- badges ---------- */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 1px 8px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
background: var(--surface-2); color: var(--muted);
white-space: nowrap;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; }
.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.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); }
/* ---------- incident detail ---------- */
.detail { padding: 0 16px calc(96px + var(--safe-bottom)); }
.detail-head {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 4px;
height: calc(var(--topbar-h) + var(--safe-top));
margin: 0 -16px; padding: var(--safe-top) 8px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.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; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card + .card, .section + .section { margin-top: 14px; }
.card-pad { padding: 14px; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; padding: 14px; font-size: 14px; }
.facts dt { color: var(--muted); }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.facts .sub { color: var(--faint); }
.section { margin-top: 22px; }
.section-title {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); margin: 0 2px 8px;
}
.labels-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.label {
display: inline-flex; max-width: 100%;
font-family: var(--mono); font-size: 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
overflow: hidden;
}
.label > span { padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label > span:first-child { background: var(--surface-2); color: var(--muted); }
.alert-item { padding: 12px 14px; display: grid; gap: 6px; }
.alert-item + .alert-item { border-top: 1px solid var(--border); }
.alert-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.alert-item-name { font-weight: 650; overflow-wrap: anywhere; }
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details[open] > summary { margin-bottom: 8px; }
.timeline { list-style: none; margin: 0; padding: 4px 0; }
.tl-item {
position: relative;
display: grid; grid-template-columns: 20px 1fr; gap: 10px;
padding: 8px 14px;
}
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
}
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
.tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
.tl-time { color: var(--faint); font-size: 12px; }
.tl-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-actions { display: flex; justify-content: flex-end; }
.note-actions .btn { color: var(--muted); }
/* The action bar sits at the bottom of the screen on a phone, and at the
bottom of the detail pane on desktop. */
.actionbar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 15;
display: flex; gap: 8px;
padding: 10px 16px calc(10px + var(--safe-bottom));
background: var(--surface);
border-top: 1px solid var(--border);
}
.actionbar .btn { min-height: 48px; }
.actionbar .btn-primary { flex: 1; font-size: 16px; }
.detail-placeholder {
display: grid; place-items: center; height: 100%;
color: var(--faint); text-align: center; padding: 32px;
}
/* ---------- sheet (dialog) ---------- */
.sheet {
width: 100%; max-width: 100%;
max-height: 88dvh;
margin: auto 0 0; padding: 0;
border: 0; border-radius: 16px 16px 0 0;
background: var(--surface); color: var(--text);
box-shadow: var(--shadow-lg);
overflow: auto;
}
.sheet::backdrop { background: rgb(0 0 0 / 40%); }
.sheet[open] { animation: sheet-up 0.18s ease-out; }
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
.sheet-form { display: grid; gap: 12px; }
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
.sheet-actions .btn { flex: 1; }
.menu { list-style: none; margin: 0 -4px; padding: 0; }
.menu-item {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 50px; padding: 0 12px;
background: none; border: 0; border-radius: var(--radius-sm);
text-align: left; font-size: 16px; cursor: pointer;
}
.menu-item:hover { background: var(--surface-2); }
.menu-item .icon { color: var(--muted); flex: none; }
.menu-item .menu-sub { margin-left: auto; color: var(--faint); font-size: 13px; }
.menu-item.danger, .menu-item.danger .icon { color: var(--crit); }
.menu-item[aria-checked="true"] { font-weight: 700; }
.menu-item[aria-checked="true"]::after { content: "✓"; margin-left: 8px; color: var(--accent); }
.menu-sep { height: 1px; background: var(--border); margin: 6px 12px; }
/* ---------- toast ---------- */
.toast {
position: fixed; left: 50%; z-index: 50;
bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px);
transform: translateX(-50%);
max-width: calc(100% - 32px);
padding: 10px 16px; border-radius: var(--radius);
background: var(--text); color: var(--bg);
font-size: 14px; font-weight: 600;
box-shadow: var(--shadow-lg);
}
.toast.error { background: var(--crit); color: #fff; }
.app.detail-open ~ .toast { bottom: calc(80px + var(--safe-bottom)); }
/* ---------- on-call ---------- */
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.avatar {
flex: none; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
.now-name { font-size: 20px; font-weight: 750; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.days { list-style: none; margin: 0; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); }
.day-name { font-weight: 650; }
.day-date { color: var(--faint); font-size: 13px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; }
.day.today { background: var(--accent-soft); }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.day.today .day-name { color: var(--accent); }
.day.past { opacity: 0.6; }
.shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
.shift-list li + li { border-top: 1px solid var(--border); }
.shift-list .muted { color: var(--faint); }
/* ---------- alerts page ---------- */
.view-alerts .chips { padding-left: 0; padding-right: 0; }
.view-alerts .list { padding-left: 0; padding-right: 0; }
.row.st-firing { --sev: var(--crit); }
.row.st-resolved { --sev: var(--ok); }
.row.no-link { cursor: default; }
/* ---------- account ---------- */
.account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; }
.form-ok {
margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok);
border-radius: var(--radius-sm); font-size: 14px;
}
.kbd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.kbd-table td { padding: 8px 14px; border-top: 1px solid var(--border); }
.kbd-table tr:first-child td { border-top: 0; }
kbd {
display: inline-block; min-width: 1.6em; padding: 1px 6px;
font-family: var(--mono); font-size: 12px; text-align: center;
background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom-width: 2px;
border-radius: 4px;
}
.only-desktop { display: none; }
.foot-note { color: var(--faint); font-size: 13px; text-align: center; margin: 24px auto; }
/* ---------- desktop ---------- */
@media (min-width: 900px) {
:root { --tabbar-h: 0px; }
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
.nav {
position: static; grid-row: 1 / span 2;
display: flex; flex-direction: column; gap: 2px;
height: auto; padding: 16px 12px;
background: var(--surface);
border-top: 0; border-right: 1px solid var(--border);
backdrop-filter: none;
}
.nav-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
color: var(--muted); font-size: 14px;
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
.topbar { display: none; }
.view { padding-bottom: 0; overflow: auto; height: 100dvh; }
.view-page { padding: 8px 32px 32px; }
.view-queue {
display: grid; grid-template-columns: minmax(340px, 420px) 1fr;
overflow: hidden;
}
.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; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
.app.detail-open .nav { display: flex; }
.app.detail-open .pane-list { display: block; }
.detail-head .back { display: none; }
.detail-head { padding-left: 16px; }
.pane-detail { position: relative; display: flex; flex-direction: column; }
.detail { flex: 1; padding: 0 32px 24px; max-width: 900px; width: 100%; }
.detail-head { margin: 0 -32px; padding-left: 32px; }
.actionbar {
position: sticky; bottom: 0;
padding: 12px 32px;
}
.actionbar .btn-primary { flex: 0 1 240px; }
.sheet {
width: min(440px, calc(100% - 32px));
margin: auto; border-radius: 14px;
}
.sheet-grab { display: none; }
.sheet-inner { padding: 20px; }
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.only-desktop { display: block; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#1b1e25"/>
<path d="M10 34h11l5-12 8 22 6-15 3 5h11" fill="none" stroke="#ff6b61" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f5f6f8" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="terdut">
<title>terdut</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css">
<script type="module" src="/js/app.js"></script>
</head>
<body>
<div id="boot" class="boot" aria-busy="true"><span class="spinner"></span></div>
<main id="login" class="login" hidden>
<form id="login-form" class="login-card" autocomplete="on">
<div class="login-brand">
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required>
</label>
<p class="form-error" role="alert" hidden></p>
<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>
</form>
</main>
<div id="app" class="app" hidden>
<nav class="nav" aria-label="Sections">
<a class="nav-brand" href="/">
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<a class="nav-link" href="/" data-section="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">
<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">
<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">
<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>
<span class="open-pill" id="open-pill" hidden></span>
</header>
<section id="view-queue" class="view view-queue" data-view="queue">
<div class="pane pane-list">
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
<div id="queue-list" class="list"></div>
</div>
<div class="pane pane-detail" id="detail" aria-live="polite"></div>
</section>
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
<dialog id="sheet" class="sheet"></dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
// Account: who you are signed in as, changing your password, signing out.
import * as api from './api.js';
import { h, clear, icon, toast } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.js';
const view = () => document.getElementById('view-more');
// Rendered once per visit rather than on every poll, so a half-typed password
// is never wiped out from under you.
export function show() {
render();
}
function render() {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
h('div', {},
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
h('div', { class: 'card' }, shortcuts())),
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.' }),
);
}
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 });
const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null;
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
// A hidden username field lets password managers file the new password
// under the right account.
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, ok, submit,
);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.';
err.hidden = false;
return;
}
submit.disabled = true;
try {
await api.setPassword(user.id, next.value, current ? current.value : '');
state.me.has_password = true;
form.reset();
if (!current) {
// From now on the form needs the current-password field.
render();
toast('Password saved');
return;
}
ok.textContent = 'Password saved. Other devices have been signed out.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return form;
}
function shortcuts() {
const rows = [
['j / k', 'Move through the queue'],
['Enter', 'Open incident'],
['Esc', 'Back to the queue'],
['f', 'Cycle the queue filter'],
['a / A', 'Acknowledge / clear acknowledgement'],
['R', 'Resolve (asks first)'],
['s', 'Assign'],
['z / Z', 'Snooze / end snooze'],
['c', 'Add a note'],
['x', 'Archive / unarchive a resolved incident'],
['r', 'Refresh now'],
];
return h('table', { class: 'kbd-table' },
h('tbody', {}, rows.map(([k, v]) =>
h('tr', {},
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
h('td', { text: v })))));
}
+91
View File
@@ -0,0 +1,91 @@
// The alert feed: Alertmanager's own records, read-only. Each row leads to the
// incident it belongs to, which is where anything can be done about it.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js';
const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
{ id: 'all', label: 'All', query: {} },
{ id: 'archived', label: 'Archived', query: { archived: 'true' } },
];
const view = () => document.getElementById('view-alerts');
let filter = 'firing';
let items = null;
let error = null;
export function show() {
render();
refresh();
}
export async function refresh() {
const requested = filter;
const f = FILTERS.find((x) => x.id === filter);
try {
const result = await api.alerts({ ...f.query, limit: 200 });
if (requested !== filter) return;
items = result;
error = null;
} catch (err) {
if (requested !== filter) return;
error = err.message;
}
render();
}
function setFilter(id) {
if (id === filter) return;
filter = id;
items = null;
render();
refresh();
}
function render() {
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Filter' },
FILTERS.map((f) => h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
})));
let body;
if (error && !items) body = h('div', { class: 'load-error', text: error });
else if (!items) body = spinner();
else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null);
else {
body = h('div', { class: 'list' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map(row));
}
clear(view(), h('div', {}, chips, body));
}
function row(a) {
const summary = (a.annotations && a.annotations.summary) || '';
const sev = a.labels && a.labels.severity;
const labels = labelSummary(Object.fromEntries(
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity')));
const linked = a.incident_id != null;
return h(linked ? 'a' : 'div', {
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
href: linked ? `/incidents/${a.incident_id}` : null,
},
h('div', { class: 'row-title', text: a.name }),
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' },
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`),
summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }),
),
);
}
+98
View File
@@ -0,0 +1,98 @@
// The terdut-server client. The page is served by the server itself, so every
// call is same-origin and carries the session cookie.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// Called whenever the server says the session is gone, so the app can put the
// login form back up wherever the user happened to be.
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
async function call(method, path, { query, body, signal } = {}) {
const url = new URL('/api' + path, location.origin);
for (const [k, v] of Object.entries(query || {})) {
if (v === '' || v == null) continue;
url.searchParams.set(k, v);
}
const headers = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
let resp;
try {
resp = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
signal,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new ApiError(0, 'Cannot reach the server.');
}
if (resp.status === 204) return null;
let data = null;
try {
data = await resp.json();
} catch {
/* non-JSON body: keep null */
}
if (!resp.ok) {
if (resp.status === 401 && path !== '/login') onUnauthorized();
const message = (data && data.error) || `Server answered ${resp.status}.`;
throw new ApiError(resp.status, message);
}
return data;
}
// session
export const me = () => call('GET', '/me');
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
export const logout = () => call('POST', '/logout');
export const setPassword = (userID, password, currentPassword) =>
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
// users
export const users = () => call('GET', '/users');
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`);
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 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 deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// 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;
}
}
+234
View File
@@ -0,0 +1,234 @@
// Entry point: session, routing, badges and keyboard.
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 * 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 account from './account.js';
const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open.
const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
more: { title: 'Account', view: account },
};
function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
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.
let depth = 0;
let listScroll = 0;
export function navigate(path, { replace = false } = {}) {
if (path === location.pathname + location.search) return;
if (replace) {
history.replaceState({ depth }, '', path);
} else {
depth += 1;
history.pushState({ depth }, '', path);
}
render();
}
export function back() {
if (depth > 0) history.back();
else navigate('/', { replace: true });
}
window.addEventListener('popstate', (e) => {
depth = (e.state && e.state.depth) || 0;
render();
});
function render() {
const prev = route;
route = parseRoute(location.pathname);
const app = $('app');
for (const [name, s] of Object.entries(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title;
}
for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
const detailOpen = route.section === 'queue' && route.incident != null;
const wasOpen = prev.section === 'queue' && prev.incident != null;
if (detailOpen && !wasOpen) listScroll = window.scrollY;
app.classList.toggle('detail-open', detailOpen);
$('view-queue').classList.toggle('has-detail', detailOpen);
if (route.section === 'queue') {
queue.show(route.incident);
incident.show(route.incident);
} else {
incident.show(null);
SECTIONS[route.section].view.show();
}
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);
updateTitle();
}
// ---------- refresh + badges ----------
async function refresh() {
state.open = await api.incidents({ sort: 'severity' });
updateBadges();
const jobs = [];
if (route.section === 'queue') {
jobs.push(queue.refresh());
if (route.incident != null) jobs.push(incident.refresh());
} else {
const v = SECTIONS[route.section].view;
if (v.refresh) jobs.push(v.refresh());
}
await Promise.allSettled(jobs);
}
function updateBadges() {
const open = state.open.length;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const pill = $('open-pill');
pill.hidden = false;
pill.textContent = open ? `${open} open` : 'All clear';
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);
updateTitle();
}
function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = SECTIONS[route.section].title;
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base;
}
// ---------- session ----------
async function boot() {
ui.initSheet();
api.setUnauthorizedHandler(showLogin);
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
try {
state.me = await api.me();
showApp();
} catch (err) {
if (err.status === 401) showLogin();
else showBootError(err);
}
}
function showBootError(err) {
ui.clear($('boot'), ui.emptyState('Cannot load terdut', err.message));
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
}
function showLogin() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
form.password.value = '';
(form.username.value ? form.password : form.username).focus();
}
async function onLogin(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.login(form.username.value.trim(), form.password.value);
form.password.value = '';
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
export async function signOut() {
try {
await api.logout();
} catch {
/* the cookie is cleared server-side or already gone */
}
showLogin();
}
function showApp() {
$('boot').hidden = true;
$('login').hidden = true;
$('app').hidden = false;
render();
poll.start(refresh);
poll.now();
}
// ---------- links + keys ----------
function interceptLinks(e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest('a[href]');
if (!a || a.target || a.origin !== location.origin || a.pathname.startsWith('/api/')) return;
e.preventDefault();
navigate(a.pathname + a.search);
}
function onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey || ui.sheetIsOpen() || $('app').hidden) return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.key === 'r') {
poll.now();
e.preventDefault();
return;
}
if (route.section !== 'queue') return;
if (route.incident != null && incident.key(e)) {
e.preventDefault();
return;
}
if (queue.key(e)) e.preventDefault();
}
boot();
+110
View File
@@ -0,0 +1,110 @@
// Formatting of times, durations and labels.
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
// Compact age for list rows: "now", "4m", "3h", "2d".
export function age(iso, now = Date.now()) {
const ms = Math.max(0, now - Date.parse(iso));
if (ms < MIN) return 'now';
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) return `${Math.floor(ms / HOUR)}h`;
return `${Math.floor(ms / DAY)}d`;
}
// "4 min ago", "3 h ago", "yesterday"-free: stays unambiguous at 3am.
export function ago(iso, now = Date.now()) {
const a = age(iso, now);
return a === 'now' ? 'just now' : `${a} ago`;
}
// Time remaining until iso, e.g. "1h 20m".
export function until(iso, now = Date.now()) {
return duration(Date.parse(iso) - now);
}
export function duration(ms) {
ms = Math.max(0, ms);
if (ms < MIN) return '<1m';
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
if (d) return h ? `${d}d ${h}h` : `${d}d`;
if (h) return m ? `${h}h ${m}m` : `${h}h`;
return `${m}m`;
}
const timeFmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const dayTimeFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
});
// Local timestamp; the date is dropped when it is today.
export function when(iso) {
const d = new Date(iso);
const today = new Date();
if (d.toDateString() === today.toDateString()) return timeFmt.format(d);
return dayTimeFmt.format(d);
}
export function isFuture(iso) {
return iso != null && Date.parse(iso) > Date.now();
}
// Local calendar dates, as the schedule stores them (YYYY-MM-DD).
export function isoDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function mondayOf(d) {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate());
r.setDate(r.getDate() - ((r.getDay() + 6) % 7));
return r;
}
export function addDays(d, n) {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
// ISO 8601 week number.
export function isoWeek(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = t.getUTCDay() || 7;
t.setUTCDate(t.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
}
export const STATUS_LABEL = {
triggered: 'Triggered',
acknowledged: 'Acknowledged',
resolved: 'Resolved',
snoozed: 'Snoozed',
firing: 'Firing',
};
export function severityClass(sev) {
const s = (sev || '').toLowerCase();
if (s === 'critical' || s === 'page' || s === 'error') return 'sev-critical';
if (s === 'warning' || s === 'warn') return 'sev-warning';
if (s) return 'sev-info';
return '';
}
// A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {})
.filter(([k]) => k !== skip)
.map(([k, v]) => `${k}=${v}`)
.join(' · ');
}
export function initial(name) {
return (name || '?').trim().charAt(0) || '?';
}
+447
View File
@@ -0,0 +1,447 @@
// Incident detail: facts, member alerts, the timeline with notes, and the
// action bar that carries everything a responder does to an incident.
import * as api from './api.js';
import * as poll from './poll.js';
import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, isFuture, severityClass, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
const pane = () => document.getElementById('detail');
let currentID = null;
let inc = null;
let events = [];
let error = null;
let busy = false;
export function show(id) {
if (id === currentID) return;
currentID = id;
inc = null;
events = [];
error = null;
if (id == null) {
renderPlaceholder();
return;
}
render();
refresh();
}
export async function refresh() {
const id = currentID;
if (id == null) return;
try {
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
if (id !== currentID) return;
inc = i;
events = t;
error = null;
} catch (err) {
if (id !== currentID) return;
error = err.status === 404 ? 'This incident does not exist.' : err.message;
}
render();
}
function renderPlaceholder() {
clear(pane(), h('div', { class: 'detail-placeholder' },
h('div', {}, icon('flag', 'icon'), h('p', { text: 'Select an incident to see its alerts and timeline.' }))));
}
function render() {
const head = h('div', { class: 'detail-head' },
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}` : '' }),
);
if (!inc) {
clear(pane(), h('div', { class: 'detail' }, head,
error ? h('div', { class: 'load-error', text: error }) : spinner()));
return;
}
// Keep the scroll position across the periodic re-render.
const scroller = document.querySelector('.pane-detail');
const top = scroller ? scroller.scrollTop : 0;
clear(pane(),
h('article', { class: 'detail' },
head,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
facts(),
groupLabels(),
alertsSection(),
timelineSection(),
),
actionBar(),
);
if (scroller) scroller.scrollTop = top;
}
function statusBadges() {
const out = [];
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
}
if (inc.archived_at) out.push(badge('Archived', 'plain'));
return out;
}
function who(id, name) {
if (id != null && id === myID()) return 'you';
return name || 'someone';
}
function facts() {
const rows = [];
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
if (inc.acknowledged_at) {
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
}
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
add('Snoozed until', when(inc.snoozed_until));
}
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}` }));
}
if (inc.archived_at) add('Archived', when(inc.archived_at));
return h('div', { class: 'card' }, h('dl', { class: 'facts' }, rows));
}
function groupLabels() {
const entries = Object.entries(inc.group_labels || {});
if (!entries.length) return null;
return h('section', { class: 'section' },
h('h2', { class: 'section-title', text: 'Grouped by' }),
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
);
}
function alertsSection() {
const list = inc.alerts || [];
const firing = list.filter((a) => a.status === 'firing').length;
return h('section', { class: 'section' },
h('h2', { class: 'section-title' },
h('span', { text: `Alerts (${list.length})` }),
firing ? h('span', { text: `${firing} firing` }) : null),
list.length
? h('div', { class: 'card' }, list.map(alertItem))
: h('div', { class: 'card card-pad', text: 'No alerts attached.' }),
);
}
function alertItem(a) {
const summary = (a.annotations && (a.annotations.summary || a.annotations.description)) || '';
const labels = Object.entries(a.labels || {});
return h('div', { class: 'alert-item' },
h('div', { class: 'alert-item-head' },
h('span', { class: 'alert-item-name', text: a.name }),
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
summary && h('div', { class: 'alert-item-summary', text: summary }),
h('div', { class: 'alert-item-foot' },
h('span', { text: `Started ${ago(a.starts_at)}` }),
h('span', { text: `Last seen ${ago(a.received_at)}` }),
a.generator_url && h('a', { href: a.generator_url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗'),
),
labels.length > 0 && h('details', {},
h('summary', { text: `${labels.length} labels` }),
h('div', { class: 'labels-wrap' }, labels.map(([k, v]) => labelChip(k, v)))),
);
}
// ---------- timeline ----------
function eventText(ev) {
const person = ev.user_id != null ? 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);
return a ? a.name : 'an alert';
};
switch (ev.type) {
case 'triggered': return ['Incident triggered'];
case 'alert_added': return [`Alert added: ${alertName()}`];
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
case 'acknowledged': return [strong(person), ' acknowledged'];
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
case 'assigned': return ['Assigned to ', strong(person)];
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
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 'notified': {
const to = person ? strong(person) : 'the fallback topic';
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
return ['Paged ', to];
}
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
case 'deadman_silent': return ['Heartbeat went silent', ev.detail ? ` (${ev.detail})` : ''];
default: return [ev.type, ev.detail ? `: ${ev.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' },
h('h2', { class: 'section-title' },
h('span', { text: 'Timeline' }),
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: addNote },
icon('note'), 'Add note')),
h('div', { class: 'card' },
sorted.length
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
: emptyState('No events yet', '')),
);
}
function timelineItem(ev) {
const mine = ev.type === 'note' && 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 || '' }),
mine && h('div', { class: 'note-actions' },
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
),
);
}
// ---------- actions ----------
const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
function actionBar() {
let primary;
let secondary;
if (inc.status === 'triggered') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
} else if (inc.status === 'acknowledged') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
} else {
primary = inc.archived_at
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
}
if (isOpen()) {
secondary = isSnoozed()
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
} else {
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
return bar;
}
// run performs one action, then reloads the incident and the queue.
async function run(fn, done) {
if (busy) return;
busy = true;
render();
try {
await fn();
if (done) toast(done);
} catch (err) {
toast(err.message, 'error');
} finally {
busy = false;
await refresh();
poll.now();
}
}
function acknowledge() {
const id = inc.id;
return run(() => api.acknowledge(id), 'Acknowledged');
}
function unacknowledge() {
const id = inc.id;
return run(() => api.unacknowledge(id), 'Acknowledgement cleared');
}
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,
});
if (ok) await run(() => api.resolve(id), 'Resolved');
}
function archive() {
const id = inc.id;
return run(() => api.archive(id), 'Archived');
}
function unarchive() {
const id = inc.id;
return run(() => api.unarchive(id), 'Unarchived');
}
function unsnooze() {
const id = inc.id;
return run(() => api.unsnooze(id), 'Snooze ended');
}
async function snooze() {
const id = inc.id;
const tomorrow9 = new Date();
tomorrow9.setDate(tomorrow9.getDate() + 1);
tomorrow9.setHours(9, 0, 0, 0);
const options = [
['30 minutes', { duration: '30m' }],
['1 hour', { duration: '1h' }],
['2 hours', { duration: '2h' }],
['4 hours', { duration: '4h' }],
['8 hours', { duration: '8h' }],
['Until 09:00 tomorrow', { until: tomorrow9.toISOString() }],
];
const spec = await openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Snooze' }),
h('p', { class: 'sheet-text', text: 'Hide it from the queue for a while. It comes back on its own.' }),
h('ul', { class: 'menu' }, options.map(([label, value]) =>
h('li', {}, h('button', { class: 'menu-item', type: 'button', onclick: () => closeSheet(value) },
icon('clock'), label)))),
]);
if (spec) await run(() => api.snooze(id, spec), 'Snoozed');
}
async function assign() {
const id = inc.id;
let list;
let onCall;
try {
[list, onCall] = await Promise.all([users(), api.onCallNow()]);
} catch (err) {
toast(err.message, 'error');
return;
}
const me = myID();
const sorted = [...list].sort((a, b) => (b.id === me) - (a.id === me) || a.username.localeCompare(b.username));
const userID = await openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Assign to' }),
h('ul', { class: 'menu', role: 'menu' }, sorted.map((u) =>
h('li', {}, h('button', {
class: 'menu-item',
type: 'button',
role: 'menuitemradio',
'aria-checked': String(u.id === inc.assigned_to_id),
onclick: () => closeSheet(u.id),
},
icon('user'),
u.id === me ? `${u.username} (you)` : u.username,
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
)))),
]);
if (userID != null) await run(() => api.assign(id, userID), 'Assigned');
}
async function addNote() {
const id = inc.id;
const content = await openSheet(() => {
const textarea = h('textarea', {
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
});
const form = h('form', {
class: 'sheet-form',
onsubmit: (e) => {
e.preventDefault();
const v = textarea.value.trim();
if (v) closeSheet(v);
},
},
h('h2', { class: 'sheet-title', text: 'Add note' }),
textarea,
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' })),
);
// Ctrl/Cmd+Enter saves, as in most note fields.
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
});
return form;
});
if (content) await run(() => api.addNote(id, content), 'Note added');
}
async function deleteNote(ev) {
const id = inc.id;
const ok = await confirm({ title: 'Delete this note?', text: ev.detail || '', confirmLabel: 'Delete', danger: true });
if (ok) await run(() => api.deleteNote(id, ev.id), 'Note deleted');
}
async function moreMenu() {
const item = (iconName, label, fn, cls = '') =>
h('li', {}, h('button', { class: `menu-item ${cls}`, type: 'button', onclick: () => closeSheet(fn) }, icon(iconName), label));
const items = [];
if (isOpen()) {
if (inc.status === 'triggered') items.push(item('check', 'Acknowledge', acknowledge));
else items.push(item('undo', 'Clear acknowledgement', unacknowledge));
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(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
items.push(item('note', 'Add note…', addNote));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
const fn = await openSheet(() => [
h('h2', { class: 'sheet-title', text: inc.title }),
h('ul', { class: 'menu' }, items),
]);
if (fn) await fn();
}
// key handles the detail's shortcuts. Returns true when it used the key.
export function key(e) {
if (!inc) {
if (e.key === 'Escape') {
back();
return true;
}
return false;
}
switch (e.key) {
case 'Escape': back(); return true;
case 'a': if (inc.status === 'triggered') acknowledge(); return true;
case 'A': if (inc.status === 'acknowledged') unacknowledge(); return true;
case 'R': if (isOpen()) resolve(); return true;
case 's': if (isOpen()) assign(); return true;
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
case 'Z': if (isSnoozed()) unsnooze(); return true;
case 'c': addNote(); return true;
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
default: return false;
}
}
+141
View File
@@ -0,0 +1,141 @@
// 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.
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';
const view = () => document.getElementById('view-oncall');
let weekStart = mondayOf(new Date());
let data = null;
let error = null;
const dayName = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
export function show() {
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
const start = weekStart;
const today = new Date();
try {
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))),
]);
if (start !== weekStart) return;
data = { now, week, upcoming };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function shiftWeek(n) {
weekStart = addDays(weekStart, 7 * n);
refresh();
}
function render() {
if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
clear(view(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
nowCard(),
weekCard(),
myShifts(),
);
}
function you(userID) {
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
}
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)),
),
);
}
function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date());
const days = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
const key = isoDate(d);
const e = byDate.get(key);
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
h('span', { class: 'day-name', text: dayName.format(d) }),
h('span', { class: 'day-date', text: dayDate.format(d) }),
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
h('div', { class: 'week-nav' },
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost label',
type: 'button',
title: 'Back to this week',
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `Week ${isoWeek(weekStart)}`,
}),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')),
),
),
h('ul', { class: 'card days' }, days),
];
}
// myShifts groups your upcoming dates into runs of consecutive days.
function myShifts() {
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
const runs = [];
for (const date of mine) {
const last = runs[runs.length - 1];
if (last && isoDate(addDays(parse(last.to), 1)) === date) last.to = date;
else runs.push({ from: date, to: date });
}
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
h('div', { class: 'card' },
runs.length
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
h('li', {},
h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
h('span', { class: 'muted', text: days(r) })),
))
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
];
}
function parse(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
function days(r) {
const n = Math.round((parse(r.to) - parse(r.from)) / 86400000) + 1;
return n === 1 ? '1 day' : `${n} days`;
}
+56
View File
@@ -0,0 +1,56 @@
// Keeps the page fresh the way the TUI does: refresh on an interval, but only
// while the page is visible, and immediately when it becomes visible again —
// which is the moment a phone is picked up after a page.
const INTERVAL = 20 * 1000;
let refreshFn = null;
let timer = 0;
let running = false;
let inFlight = null;
export function start(fn) {
refreshFn = fn;
running = true;
schedule();
}
export function stop() {
running = false;
clearInterval(timer);
}
// now refreshes straight away and restarts the interval, after an action.
export function now() {
if (!running) return Promise.resolve();
schedule();
return tick();
}
function tick() {
if (!refreshFn) return Promise.resolve();
// Collapse overlapping refreshes into the one already under way.
if (!inFlight) {
inFlight = Promise.resolve()
.then(refreshFn)
.catch(() => {})
.finally(() => {
inFlight = null;
});
}
return inFlight;
}
function schedule() {
clearInterval(timer);
if (running && !document.hidden) timer = setInterval(tick, INTERVAL);
}
document.addEventListener('visibilitychange', () => {
if (!running) return;
if (!document.hidden) tick();
schedule();
});
window.addEventListener('online', () => {
if (running) tick();
});
+182
View File
@@ -0,0 +1,182 @@
// The incident queue: filter chips and a list of incident rows.
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 { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
const FILTERS = [
{ id: 'open', label: 'Open', query: { sort: 'severity' } },
{ id: 'triggered', label: 'Triggered', query: { status: 'triggered', sort: 'severity' } },
{ id: 'acknowledged', label: 'Acked', query: { status: 'acknowledged', sort: 'severity' } },
{ id: 'snoozed', label: 'Snoozed', query: { snoozed: 'true' } },
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
{ id: 'archived', label: 'Archived', query: { status: 'resolved', archived: 'true' } },
];
const EMPTY = {
open: ['All clear', 'Nothing open right now.'],
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
archived: ['Nothing archived', ''],
};
let filter = loadFilter();
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
if (FILTERS.some((x) => x.id === f)) return f;
} catch {
/* storage unavailable */
}
return 'open';
}
function saveFilter() {
try {
sessionStorage.setItem('terdut.queue.filter', filter);
} catch {
/* storage unavailable */
}
}
export function show(incidentID) {
selected = incidentID;
if (!built) {
renderChips();
built = true;
}
renderList();
}
export async function refresh({ fresh = false } = {}) {
const f = FILTERS.find((x) => x.id === filter);
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);
if (requested !== filter) return;
items = result;
error = null;
} catch (err) {
if (requested !== filter) return;
error = err.message;
}
renderList();
}
function setFilter(id) {
if (id === filter) return;
filter = id;
saveFilter();
items = null;
cursor = -1;
renderChips();
renderList();
refresh({ fresh: true });
}
function renderChips() {
const el = document.getElementById('queue-filters');
clear(el, FILTERS.map((f) =>
h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}),
));
}
function renderList() {
const el = document.getElementById('queue-list');
if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, spinner());
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
return;
}
clear(el,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);
}
function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved';
let status;
if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
else if (inc.status === 'acknowledged') {
const by = inc.acknowledged_by_id === myID() ? 'you' : inc.acknowledged_by;
status = badge(`Acked${by ? ' · ' + by : ''}`, 'st-acknowledged');
}
else status = badge('Triggered', 'st-triggered');
let assignee = null;
if (inc.assigned_to_id != null) {
assignee = h('span', { text: inc.assigned_to_id === myID() ? '→ you' : `→ ${inc.assigned_to}` });
}
// 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}`))));
return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) },
},
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
h('div', { class: 'row-meta' },
status,
assignee,
labels && h('span', { class: 'labels', text: labels }),
),
);
}
// key handles j/k/enter on the list. Returns true when it used the key.
export function key(e) {
if (!items || !items.length) return false;
if (e.key === 'j' || e.key === 'ArrowDown') {
cursor = Math.min(items.length - 1, cursor + 1);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
cursor = Math.max(0, cursor - 1);
} else if (e.key === 'Enter' && cursor >= 0) {
navigate(`/incidents/${items[cursor].id}`);
return true;
} else if (e.key === 'f') {
const i = FILTERS.findIndex((x) => x.id === filter);
setFilter(FILTERS[(i + 1) % FILTERS.length].id);
return true;
} else {
return false;
}
renderList();
const el = document.querySelector(`#queue-list [data-index="${cursor}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
return true;
}
+31
View File
@@ -0,0 +1,31 @@
// State shared between views: who is signed in, the user list, and the open
// queue that drives the badges.
import * as api from './api.js';
export const state = {
me: null, // { user, has_password }
open: [], // the default queue: open, not snoozed
};
export function myID() {
return state.me ? state.me.user.id : null;
}
// The user list changes rarely; it is fetched once and then at most every
// five minutes, for the assign sheet and for display names.
let usersCache = null;
let usersAt = 0;
export async function users() {
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
usersCache = await api.users();
usersAt = Date.now();
}
return usersCache;
}
export function reset() {
state.me = null;
state.open = [];
usersCache = null;
}
+173
View File
@@ -0,0 +1,173 @@
// DOM helpers, the bottom sheet, confirmation and toasts.
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
else if (k in el && typeof v !== 'string') el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el, ...children) {
el.replaceChildren();
append(el, children);
return el;
}
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = {
back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
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'],
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'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
};
const SVG = 'http://www.w3.org/2000/svg';
export function icon(name, cls = 'icon') {
const svg = document.createElementNS(SVG, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', cls);
for (const d of ICONS[name] || []) {
let node;
if (d.startsWith('circle:')) {
const [cx, cy, r] = d.slice(7).split(',');
node = document.createElementNS(SVG, 'circle');
node.setAttribute('cx', cx);
node.setAttribute('cy', cy);
node.setAttribute('r', r);
} else {
node = document.createElementNS(SVG, 'path');
node.setAttribute('d', d);
}
svg.append(node);
}
return svg;
}
// ---------- sheet ----------
const sheet = () => document.getElementById('sheet');
let sheetResolve = null;
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
// and resolves with whatever closeSheet is given, or null when dismissed.
export function openSheet(build) {
const dlg = sheet();
if (dlg.open) closeSheet(null);
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
append(inner, [build()]);
clear(dlg, inner);
dlg.showModal();
const first = dlg.querySelector('[autofocus]');
if (first) first.focus();
return new Promise((resolve) => {
sheetResolve = resolve;
});
}
export function closeSheet(value = null) {
const dlg = sheet();
const resolve = sheetResolve;
sheetResolve = null;
if (dlg.open) dlg.close();
if (resolve) resolve(value);
}
export function sheetIsOpen() {
return sheet().open;
}
export function initSheet() {
const dlg = sheet();
// A tap on the backdrop lands on the dialog element itself.
dlg.addEventListener('click', (e) => {
if (e.target === dlg) closeSheet(null);
});
dlg.addEventListener('cancel', (e) => {
e.preventDefault();
closeSheet(null);
});
}
// confirm asks a yes/no question in the sheet.
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
return openSheet(() => [
h('h2', { class: 'sheet-title', text: title }),
text && h('p', { class: 'sheet-text', text }),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
h('button', {
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
type: 'button',
autofocus: true,
onclick: () => closeSheet(true),
text: confirmLabel,
}),
),
]).then((v) => v === true);
}
// ---------- toast ----------
let toastTimer = 0;
export function toast(message, kind = '') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = `toast ${kind}`;
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.hidden = true;
}, kind === 'error' ? 5000 : 2500);
}
// ---------- misc ----------
export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
}
export function emptyState(title, text, iconName) {
return h('div', { class: 'empty' },
iconName && icon(iconName),
h('strong', { text: title }),
text && h('span', { text }),
);
}
export function spinner() {
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "terdut",
"short_name": "terdut",
"description": "Incident queue and on-call for terdut-server",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f1115",
"theme_color": "#1b1e25",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+135
View File
@@ -0,0 +1,135 @@
// Package web serves the web UI, compiled into the binary.
//
// There is no build step: the files under static/ are what the browser gets.
// The page talks to the server's own /api over the same origin, signed in with
// the session cookie from POST /api/login.
package web
import (
"bytes"
"crypto/sha256"
"embed"
"encoding/base64"
"errors"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
//go:embed static
var files embed.FS
// asset is one embedded file, with its validator computed once at startup.
type asset struct {
body []byte
etag string
ctype string
cache string
}
// Handler serves the embedded site. A path without a file extension that
// matches no file gets index.html, so a deep link such as /incidents/42 — the
// target of a notification tap — survives a reload; the page reads the path
// and renders the right view. A missing file with an extension is a real 404.
func Handler() (http.Handler, error) {
root, err := fs.Sub(files, "static")
if err != nil {
return nil, err
}
assets := make(map[string]*asset)
err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := fs.ReadFile(root, p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
assets["/"+p] = &asset{
body: b,
etag: `"` + base64.RawURLEncoding.EncodeToString(sum[:16]) + `"`,
ctype: contentType(p),
cache: cacheControl(p),
}
return nil
})
if err != nil {
return nil, err
}
index, ok := assets["/index.html"]
if !ok {
return nil, errors.New("web: static/index.html is missing")
}
// embed.FS reports a zero ModTime, so http.FileServerFS would emit no
// validator and every asset would be refetched in full on every load.
// Hence the ETag above and ServeContent below, with a zero time that
// suppresses Last-Modified.
var noTime time.Time
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
p := path.Clean(r.URL.Path)
f, ok := assets[p]
if !ok {
if path.Ext(p) != "" {
http.NotFound(w, r)
return
}
f = index
}
w.Header().Set("Content-Type", f.ctype)
w.Header().Set("Cache-Control", f.cache)
w.Header().Set("ETag", f.etag)
// The page loads nothing from anywhere else, so the policy can say so
// outright rather than carve out exceptions.
w.Header().Set("Content-Security-Policy",
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "+
"connect-src 'self'; manifest-src 'self'; form-action 'self'; "+
"frame-ancestors 'none'; base-uri 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
http.ServeContent(w, r, "", noTime, bytes.NewReader(f.body))
}), nil
}
func contentType(p string) string {
switch path.Ext(p) {
case ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "text/javascript; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".webmanifest":
return "application/manifest+json"
default:
return "application/octet-stream"
}
}
// cacheControl keeps index.html revalidating on every load, because it names
// the current asset paths. Assets carry an ETag, so a five-minute window costs
// one conditional request after a deploy rather than a stale page.
func cacheControl(p string) string {
if strings.HasSuffix(p, ".html") {
return "no-cache"
}
return "public, max-age=300"
}
+198
View File
@@ -0,0 +1,198 @@
//go:build migrate
// Command sqlite-to-postgres copies a terdut SQLite database into a freshly
// migrated Postgres one. It exists for exactly one upgrade — the one that moved
// this server off SQLite — and should be deleted once the installs that need it
// have run it. The modernc.org/sqlite dependency goes with it.
//
// Build-tagged so the dependency stays out of the server binary and out of a
// plain `go build ./...`:
//
// go run -tags migrate ./scripts/sqlite-to-postgres.go \
// -sqlite /path/to/terdut.db \
// -dsn 'postgres://terdut:secret@localhost:5432/terdut?sslmode=disable'
//
// The Postgres side must already have the schema: start the new server once
// against an empty database, let it migrate, stop it, then run this. The copy
// refuses to touch a database that already has rows, so a second run cannot
// double-insert.
//
// Ids are preserved, which is what keeps every foreign key — incident_alerts,
// incident_events, notifications, the ack tokens — pointing at the same rows it
// pointed at before. The identity sequences are moved past the copied ids at the
// end, so the first row the server writes afterwards does not collide.
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// tables are copied parents first: every foreign key points at a table earlier
// in this list.
var tables = []struct {
name string
columns []string
// jsonb marks columns that were TEXT in SQLite and are jsonb in Postgres,
// so the insert can cast them.
jsonb []string
// sequence is the identity sequence to advance afterwards, empty when the
// table has no generated id.
sequence string
}{
{name: "users", columns: []string{"id", "username", "email", "created_at", "ntfy_topic", "password_hash"}, sequence: "users_id_seq"},
{name: "api_keys", columns: []string{"id", "user_id", "key_hash", "name", "created_at", "last_used_at"}, sequence: "api_keys_id_seq"},
{name: "sessions", columns: []string{"id", "token_hash", "user_id", "created_at", "last_seen_at", "expires_at", "user_agent"}, sequence: "sessions_id_seq"},
{name: "alerts", columns: []string{"id", "fingerprint", "name", "status", "labels", "annotations", "starts_at", "ends_at", "generator_url", "received_at", "archived_at", "resolution_source"}, jsonb: []string{"labels", "annotations"}, sequence: "alerts_id_seq"},
{name: "schedule_entries", columns: []string{"id", "user_id", "date", "created_at"}, sequence: "schedule_entries_id_seq"},
{name: "incidents", columns: []string{"id", "group_key", "title", "group_labels", "status", "severity", "triggered_at", "acknowledged_by", "acknowledged_at", "assigned_to", "snoozed_until", "resolved_at", "resolution_source", "archived_at"}, jsonb: []string{"group_labels"}, sequence: "incidents_id_seq"},
{name: "incident_alerts", columns: []string{"incident_id", "alert_id", "added_at"}},
{name: "incident_events", columns: []string{"id", "incident_id", "type", "user_id", "alert_id", "detail", "created_at"}, sequence: "incident_events_id_seq"},
{name: "notifications", columns: []string{"id", "incident_id", "user_id", "topic", "kind", "created_at", "send_after", "attempts", "sent_at", "last_error"}, sequence: "notifications_id_seq"},
{name: "incident_ack_tokens", columns: []string{"token_hash", "incident_id", "user_id", "created_at", "expires_at"}},
}
func main() {
sqlitePath := flag.String("sqlite", "", "path to the existing terdut SQLite database")
dsn := flag.String("dsn", "", "Postgres DSN of the migrated, empty database")
flag.Parse()
if *sqlitePath == "" || *dsn == "" {
log.Fatal("both -sqlite and -dsn are required")
}
src, err := sql.Open("sqlite", *sqlitePath)
if err != nil {
log.Fatalf("open sqlite: %v", err)
}
defer src.Close()
dst, err := sql.Open("pgx", *dsn)
if err != nil {
log.Fatalf("open postgres: %v", err)
}
defer dst.Close()
if err := dst.Ping(); err != nil {
log.Fatalf("ping postgres: %v", err)
}
if err := assertEmpty(dst); err != nil {
log.Fatalf("%v", err)
}
// One transaction for the whole copy: a run that dies half way leaves the
// target as it found it, rather than a partial database someone has to
// recognise as partial.
tx, err := dst.Begin()
if err != nil {
log.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck
for _, t := range tables {
n, err := copyTable(src, tx, t.name, t.columns, t.jsonb)
if err != nil {
log.Fatalf("copy %s: %v", t.name, err)
}
log.Printf("%-20s %d row(s)", t.name, n)
}
for _, t := range tables {
if t.sequence == "" {
continue
}
if err := advanceSequence(tx, t.sequence, t.name); err != nil {
log.Fatalf("advance %s: %v", t.sequence, err)
}
}
if err := tx.Commit(); err != nil {
log.Fatalf("commit: %v", err)
}
log.Print("done")
}
// assertEmpty refuses a target that already holds data, so running this twice
// cannot duplicate anything.
func assertEmpty(dst *sql.DB) error {
for _, t := range tables {
var n int64
if err := dst.QueryRow("SELECT COUNT(*) FROM " + t.name).Scan(&n); err != nil {
return fmt.Errorf("count %s (has the new server migrated this database?): %w", t.name, err)
}
if n > 0 {
return fmt.Errorf("%s already has %d row(s): the target must be empty", t.name, n)
}
}
return nil
}
func copyTable(src *sql.DB, tx *sql.Tx, table string, columns, jsonb []string) (int64, error) {
rows, err := src.Query("SELECT " + strings.Join(columns, ", ") + " FROM " + table)
if err != nil {
return 0, err
}
defer rows.Close()
insert := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" +
strings.Join(valuePlaceholders(columns, jsonb), ", ") + ")"
stmt, err := tx.Prepare(insert)
if err != nil {
return 0, err
}
defer stmt.Close()
var copied int64
for rows.Next() {
// Scanning into any lets the SQLite driver decide each column's Go type
// and hands it straight back to pgx, which is all this needs: the column
// types match on both sides, apart from the JSON casts above.
values := make([]any, len(columns))
targets := make([]any, len(columns))
for i := range values {
targets[i] = &values[i]
}
if err := rows.Scan(targets...); err != nil {
return copied, err
}
if _, err := stmt.Exec(values...); err != nil {
return copied, fmt.Errorf("insert row %d: %w", copied+1, err)
}
copied++
}
return copied, rows.Err()
}
// valuePlaceholders numbers the placeholders, casting the columns that became
// jsonb: pgx sends a Go string as text, and Postgres will not assign text to a
// jsonb column without being told.
func valuePlaceholders(columns, jsonb []string) []string {
isJSON := make(map[string]bool, len(jsonb))
for _, c := range jsonb {
isJSON[c] = true
}
out := make([]string, len(columns))
for i, c := range columns {
out[i] = fmt.Sprintf("$%d", i+1)
if isJSON[c] {
out[i] += "::jsonb"
}
}
return out
}
// advanceSequence puts an identity sequence past the largest copied id. Without
// it the first insert after the migration would reuse id 1.
func advanceSequence(tx *sql.Tx, sequence, table string) error {
_, err := tx.Exec(fmt.Sprintf(
`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)`,
sequence, table))
return err
}