Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e5e03f4e | |||
| 4d62c1130b | |||
| 3183e7e5c5 | |||
| 94d23a593c | |||
| b0a02c010b | |||
| 303e7a3365 | |||
| 7c87ae2af8 | |||
| 4c85e7646c | |||
| 05f82220a6 | |||
| 5227eb0d5f | |||
| 74359c72ab | |||
| 43f69272f0 | |||
| 2de5c8412d | |||
| a4fbd60441 | |||
| 1377d9005b | |||
| e3ad19c110 | |||
| a8ee742533 | |||
| 041e159e2a | |||
| cede8743a8 | |||
| cc31c993dd | |||
| 21f0eec807 | |||
| dc39e3a5d3 | |||
| 989425e550 | |||
| 5b1ab2c568 | |||
| e78f49461a | |||
| 9abf07f2cb | |||
| 4cec26edde | |||
| dc3879eca6 | |||
| 9669b8f477 | |||
| a7871ed7c6 | |||
| 79f5db2636 | |||
| 84146fc903 | |||
| c6f1fe317e | |||
| f46e5f5729 | |||
| 69fcc24a4d | |||
| 6a4f902e38 | |||
| 5f9c202d65 | |||
| 477454ec3c | |||
| 10812606bf | |||
| 94dec19976 | |||
| 9046f6e026 | |||
| 03504b61be |
@@ -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,8 +82,70 @@ jobs:
|
||||
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
- name: Test
|
||||
run: go 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 [ -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: Go vulnerability scan (govulncheck)
|
||||
run: make security-go
|
||||
|
||||
- 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
|
||||
|
||||
@@ -35,17 +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" .
|
||||
|
||||
- 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
|
||||
@@ -62,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
|
||||
@@ -134,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.
|
||||
@@ -176,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: |
|
||||
@@ -188,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 }}
|
||||
@@ -200,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Read by the `release` skill (~/.claude/skills/release).
|
||||
#
|
||||
# Only what the Makefile cannot already say. IMAGE, HELM_CHART and HELM_REPO come from
|
||||
# `make release-vars`, so they have one definition and cannot drift from what is built.
|
||||
#
|
||||
# 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
|
||||
|
||||
# riksdata writes appVersion: "v0.3.1", rd-web writes a bare 0.5.0; this repo writes the
|
||||
# v, like riksdata. Nothing reads the field -- .gitea/workflows/release.yaml stamps both
|
||||
# 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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,259 @@
|
||||
REGISTRY := git.ryuvia.com
|
||||
# The personal namespace, not ryuvia — deliberately, and for one reason: Gitea
|
||||
# scopes package visibility to the owner with no per-package override, so
|
||||
# ryuvia/* is private because the org is. Publishing here keeps the image and
|
||||
# chart anonymously pullable, so no pull secret is needed in the cluster and
|
||||
# Flux needs no registry credentials. Same choice riksdata and rd-web made.
|
||||
OWNER := niklas
|
||||
|
||||
IMAGE := $(REGISTRY)/$(OWNER)/terdut-server
|
||||
HELM_CHART := charts/terdut-server
|
||||
HELM_REPO := oci://$(REGISTRY)/$(OWNER)
|
||||
|
||||
# go.mod pins an exact patch release so nobody builds the shipped binary with a
|
||||
# toolchain carrying known stdlib CVEs. Fedora's Go package overrides the
|
||||
# upstream GOTOOLCHAIN default to `local`, which turns that pin into a hard
|
||||
# failure on a dev box one patch behind, so restore the upstream default here.
|
||||
export GOTOOLCHAIN ?= auto
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
## --- checks ---
|
||||
#
|
||||
# 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. 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 (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
|
||||
# than a failed build. It passes today; if it ever costs more than it catches,
|
||||
# the honest fix is to teach CI -race too, not to quietly drop it here.
|
||||
.PHONY: lint
|
||||
lint: ## go vet
|
||||
go vet ./...
|
||||
|
||||
# Copied from ci.yaml rather than simplified, because 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 "$$out" ]` reads as success. See 9046f6e.
|
||||
.PHONY: fmt
|
||||
fmt: ## Report unformatted files
|
||||
@if ! unformatted=$$(gofmt -l .); then \
|
||||
echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \
|
||||
fi; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
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) $(HELM_LINT_SET)
|
||||
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
|
||||
$(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 \
|
||||
$(HELM_LINT_SET) --set networking.listener=https-terdut >/dev/null
|
||||
|
||||
## --- release ---
|
||||
|
||||
# The release process (~/.claude/skills/release) reads these rather than restating them.
|
||||
# One definition, so the version that gets tagged, the image that gets pushed and the chart
|
||||
# the wrapper pins cannot drift apart in a second copy.
|
||||
.PHONY: release-vars
|
||||
release-vars: ## Print the variables the release process reads
|
||||
@printf 'IMAGE=%s\nHELM_CHART=%s\nHELM_REPO=%s\n' '$(IMAGE)' '$(HELM_CHART)' '$(HELM_REPO)'
|
||||
|
||||
# There is deliberately no build/push/helm-package/helm-push/release here, unlike
|
||||
# riksdata and rd-web. .gitea/workflows/release.yaml owns publishing for this repo,
|
||||
# and it does two things a local make cannot: it builds linux/amd64 and linux/arm64
|
||||
# through buildx, and it stamps the chart's version and appVersion from the tag. A
|
||||
# `docker build && docker push` target would push a single-architecture image over
|
||||
# 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)
|
||||
@@ -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,43 +119,69 @@ than an `Ingress`. TLS is terminated at the gateway, so the server itself never
|
||||
| `networking.listener` | `""` | Gateway listener (`sectionName`) to bind to. Empty attaches to every matching listener, **including plaintext HTTP** — set it to the HTTPS listener's name to serve TLS only |
|
||||
| `networking.servicePort` | `8080` | Port the route forwards to; keep in sync with `service.port` |
|
||||
| `bootstrap.enabled` | `true` | Runs a post-install hook that creates the first user and stores its API key in the `<release>-admin-key` Secret. Already-bootstrapped servers are left alone |
|
||||
| `backupSidecar.enabled` | `true` | Adds an idle `python` sidecar and the [k8up](https://k8up.io/) annotations that dump the database through it |
|
||||
| `database.dsn` | `""` | **Required.** Postgres DSN, with no password in it. The chart provisions no database |
|
||||
| `database.passwordSecret.name` | `""` | Secret supplying `PGPASSWORD`. With the Zalando postgres operator, the Secret it generates for the role |
|
||||
| `database.passwordSecret.key` | `password` | Key within that Secret |
|
||||
|
||||
The API key travels in an `Authorization: Bearer` header, so set `networking.listener` whenever the
|
||||
hostname is reachable outside a trusted network.
|
||||
|
||||
#### The database
|
||||
|
||||
The chart provisions no database: it takes a DSN and expects a Postgres that already exists. In this
|
||||
cluster the wrapper chart declares an `acid.zalan.do/v1 postgresql` CR; anywhere else, any reachable
|
||||
Postgres 14+ will do.
|
||||
|
||||
The DSN carries no password. pgx falls back to libpq's environment variables for whatever the DSN
|
||||
leaves out, so the password arrives as `PGPASSWORD` from a Secret and never appears in values, in
|
||||
the rendered manifest or in `kubectl describe pod`. With the postgres operator that Secret is the
|
||||
one it generates for the role, so a rebuild mints a new password with nothing to keep in sync —
|
||||
the same wiring miniflux uses.
|
||||
|
||||
The server migrates its own schema on startup, so a new database only has to exist and be writable.
|
||||
|
||||
#### Backups
|
||||
|
||||
The server image is `FROM scratch` — the binary and nothing else — so there is no interpreter to
|
||||
run a database dump in, and the database runs in WAL mode, where a file-level copy of the volume is
|
||||
not crash-consistent. The chart therefore ships an idle `python:*-alpine` sidecar that shares the
|
||||
data volume, and points k8up's `backupcommand` at it with `k8up.io/backupcommand-container`. Without
|
||||
that annotation k8up execs into `.spec.containers[0]` and the dump fails.
|
||||
Postgres is backed up where it runs, not from here. The database pod carries a
|
||||
[k8up](https://k8up.io/) `k8up.io/backupcommand` annotation that streams a `pg_dump`, the same way
|
||||
gitea and immich do in this cluster.
|
||||
|
||||
The dump is buffered and sanity-checked before its first byte reaches stdout, because k8up streams
|
||||
stdout straight into Restic: a dump that dies partway is otherwise stored as a silently truncated
|
||||
snapshot that k8up still reports as successful.
|
||||
|
||||
Set `backupSidecar.enabled=false` if you back the volume up some other way.
|
||||
This used to be the app's problem: the SQLite database lived on a PVC beside the server, the image
|
||||
is `FROM scratch` with no interpreter to dump it, and WAL mode makes a file-level copy of the volume
|
||||
non-crash-consistent — so the chart shipped an idle `python:*-alpine` sidecar purely to give k8up
|
||||
somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all gone.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Two kinds of setting, split by who changes them and how often.
|
||||
|
||||
**Where the server is plugged in** stays in the environment: the listen address,
|
||||
the database DSN, the ntfy URL and token, the public URL. They are needed before
|
||||
the database is open, and two of them are credentials.
|
||||
|
||||
**How the server behaves** lives in the database and is edited by an
|
||||
administrator in the web UI or through `PUT /api/admin/settings`, taking effect
|
||||
on the next sweep rather than at the next restart. The variables below marked
|
||||
**seed** are the value each of those starts from: written once, on first start,
|
||||
and never overwritten afterwards — a redeploy cannot put a chart's default back
|
||||
over an administrator's edit.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
|
||||
| `TERDUT_DB_PATH` | `terdut.db` | Path to the SQLite database file |
|
||||
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | How long a resolved alert or incident stays in the default list before being auto-archived |
|
||||
| `TERDUT_STALE_AFTER` | `6h` | How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
|
||||
| `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | Which alerts are [dead man's switches](#dead-mans-switch). `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
|
||||
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
|
||||
| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | **seed.** How long a resolved alert or incident stays in the default list before being auto-archived |
|
||||
| `TERDUT_STALE_AFTER` | `6h` | **seed.** How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
|
||||
| `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | The **default** matchers a team starts with — switches are per team now, and this seeds teams that have no configuration of their own. `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
|
||||
| `TERDUT_DEADMAN_TIMEOUT` | `15m` | How long a heartbeat may go unheard before its switch is declared dead — **must be shorter than the `repeat_interval` of the route carrying it**. `0` disables dead man's switch handling |
|
||||
| `TERDUT_DEADMAN_SEVERITY` | `critical` | Severity a dead man's switch incident opens at |
|
||||
| `TERDUT_NTFY_URL` | — | ntfy server to publish push notifications to. Empty disables notifications entirely |
|
||||
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
|
||||
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
||||
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server, for the link and Acknowledge button inside a notification |
|
||||
| `TERDUT_NOTIFY_REPEAT` | `15m` | How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
||||
| `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` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
||||
|
||||
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
||||
|
||||
@@ -122,19 +195,39 @@ In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and
|
||||
|
||||
## Alertmanager configuration
|
||||
|
||||
Add terdut-server as a webhook receiver in your `alertmanager.yml`:
|
||||
Alerts arrive on a team's **integration key**, which says both that the sender
|
||||
may post and which team the alerts belong to. Mint one as an owner of the team:
|
||||
|
||||
```bash
|
||||
curl -X POST https://terdut.example.com/api/teams/1/integrations \
|
||||
-H "Authorization: Bearer $TERDUT_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"prod alertmanager"}'
|
||||
```
|
||||
|
||||
The response carries the key and the full URL **once**; only a SHA-256 hash is
|
||||
stored. Put it in your `alertmanager.yml`:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
- name: terdut
|
||||
webhook_configs:
|
||||
- url: http://terdut-server:8080/api/alertmanager/webhook
|
||||
- url: http://terdut-server:8080/api/integrations/<key>/alertmanager
|
||||
send_resolved: true
|
||||
|
||||
route:
|
||||
receiver: terdut
|
||||
```
|
||||
|
||||
The whole URL is a credential, so treat it like one. Alertmanager 0.26 and
|
||||
later can read it from a file with `url_file:` instead, which keeps it out of
|
||||
your configuration repository:
|
||||
|
||||
```yaml
|
||||
- url_file: /etc/alertmanager/secrets/terdut-webhook-url/url
|
||||
send_resolved: true
|
||||
```
|
||||
|
||||
The webhook endpoint requires no authentication.
|
||||
|
||||
If you use the [dead man's switch](#dead-mans-switch) — and the default configuration does — give
|
||||
@@ -284,6 +377,50 @@ exhausts its retries. Written from the result rather than at enqueue, so the
|
||||
timeline says what actually happened — and a page that never landed is visible
|
||||
instead of looking the same as one that did.
|
||||
|
||||
### Escalation
|
||||
|
||||
Without a ladder, an unacknowledged incident re-pages the same topic every
|
||||
`notify_repeat` forever. That is a louder version of the same silence: if the
|
||||
person on call is asleep, out of signal, or has left, nothing else happens.
|
||||
|
||||
A team can configure an ordered ladder instead. Each level has a timeout and a
|
||||
set of targets, and a target is either a named person or **whoever the team's
|
||||
rota says is on call today** — the target that keeps working when the rota
|
||||
changes and nobody remembers to edit the policy.
|
||||
|
||||
```
|
||||
level 1 5m oncall the rota gets first refusal
|
||||
level 2 5m user:bob then a named second
|
||||
then repeat_count more rounds
|
||||
then the team's fallback topic, once
|
||||
```
|
||||
|
||||
When a level's timeout passes with the incident still `triggered`, the next
|
||||
level is paged. Off the end of the ladder the whole thing runs again
|
||||
`repeat_count` times, and after that the team's `fallback_topic` is paged once
|
||||
as the end of the line. The incident stays open throughout: running out of
|
||||
people to wake is not the same as somebody answering.
|
||||
|
||||
**Acknowledging or resolving stops it**, which is the point — continuing to wake
|
||||
people after somebody has said "I have this" is how a tool teaches people to
|
||||
mute it. **Snoozing pauses it**: a deliberate "not now" holds the ladder where
|
||||
it is, and it resumes when the snooze runs out.
|
||||
|
||||
Every step is on the incident's timeline with the level and the names it woke,
|
||||
so somebody reading it afterwards can tell why their phone rang at 04:00. A
|
||||
level whose targets are all unreachable — no ntfy topic, a disabled account, an
|
||||
empty rota — is recorded as `nobody reachable` and the ladder moves on rather
|
||||
than stalling on a rung that cannot ring.
|
||||
|
||||
**Reminders and escalation never both run.** A team with a ladder gets
|
||||
escalation; a team without keeps the reminder behaviour exactly as it was. Two
|
||||
pages for one silence is the surest way to get a tool muted.
|
||||
|
||||
The ladder's `fallback_topic` is per team, unlike `TERDUT_NTFY_FALLBACK_TOPIC`,
|
||||
which is the install-wide topic used when an incident opens with nobody on call.
|
||||
They answer different questions: one is "nobody was scheduled", the other is
|
||||
"everybody scheduled has been tried".
|
||||
|
||||
### Stale alert expiry
|
||||
|
||||
A resolved webhook is the only signal that an alert has stopped firing, so a
|
||||
@@ -318,11 +455,23 @@ kube-prometheus-stack already ships the alert for this. `Watchdog` is
|
||||
nothing unless something downstream notices it stop. That is what
|
||||
`TERDUT_DEADMAN_MATCHERS` defaults to.
|
||||
|
||||
**Switches belong to a team**, which decides which of its own alerts are
|
||||
heartbeats and how long a silence has to last. An owner sets them through
|
||||
`PUT /api/teams/{teamID}/deadman`; a missed heartbeat opens an incident in the
|
||||
team whose integration received it.
|
||||
|
||||
The environment variables are the starting point, not the setting: at startup
|
||||
every team **without** a configuration of its own is given one from them, and an
|
||||
owner's later edit is never overwritten by a redeploy. A team created after
|
||||
that starts watching nothing until its owner says otherwise — inheriting an
|
||||
install-wide heartbeat would page a new team about a source it has never heard
|
||||
of.
|
||||
|
||||
A matcher is a set of exact label conditions, one of which must be the
|
||||
`alertname`:
|
||||
`alertname`, in the same format the environment variable uses:
|
||||
|
||||
```
|
||||
TERDUT_DEADMAN_MATCHERS="alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat"
|
||||
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
|
||||
```
|
||||
|
||||
**The unit of monitoring is the fingerprint, not the alert name.** Two clusters
|
||||
@@ -375,29 +524,108 @@ 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/integrations/{key}/alertmanager`,
|
||||
`/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.
|
||||
|
||||
Two kinds of user exist. An **administrator** manages accounts: creating and
|
||||
deleting users, setting anybody's password, minting keys for anybody, and
|
||||
granting the flag itself. Everybody else works incidents — acknowledging,
|
||||
assigning, snoozing, resolving, noting — and manages their own account and
|
||||
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
||||
|
||||
The first user, from `/api/bootstrap`, is an administrator. Users created
|
||||
afterwards are not, until an administrator says so. An install always keeps at
|
||||
least one: the last administrator can be neither deleted nor demoted, and
|
||||
nobody can delete or demote themselves.
|
||||
|
||||
Endpoints that require the flag answer `403` with
|
||||
`{"error":"administrator access required"}`.
|
||||
|
||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
||||
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
||||
sees exactly the teams they belong to — an administrator is not implicitly in
|
||||
every team, because administration is about accounts, not about reading other
|
||||
people's incidents. Within a team an **owner** configures it (schedule,
|
||||
integrations, membership) and a **member** works its incidents.
|
||||
|
||||
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
||||
incident exists is itself something only its team should learn.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `POST` | `/api/users/{id}/api-keys` | Issue API key `{"name"}` — key shown once |
|
||||
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key |
|
||||
**admin** marks an endpoint that requires the administrator flag; **self or
|
||||
admin** marks one you may use on your own account and an administrator may use
|
||||
on anybody's.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
||||
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
||||
| `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once |
|
||||
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key |
|
||||
|
||||
### Administration
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
|
||||
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
|
||||
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds |
|
||||
|
||||
### Alert ingestion
|
||||
|
||||
Alerts arrive on a team's integration key. The key is both the credential and the
|
||||
routing: it says that the sender may post, and which team the alerts belong to.
|
||||
Create one with `POST /api/teams/{teamID}/integrations`, which returns the key
|
||||
and the full URL once and stores only a SHA-256 hash.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/alertmanager/webhook` | Alertmanager v4 webhook receiver (no auth) |
|
||||
| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key |
|
||||
|
||||
This is the only way in. The pre-teams `POST /api/alertmanager/webhook` took no
|
||||
credential at all — anything able to reach the port could open an incident —
|
||||
and was removed in v0.13.0 once senders had moved onto keys.
|
||||
|
||||
### Teams
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
||||
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
|
||||
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
||||
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
|
||||
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
||||
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
|
||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
||||
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
||||
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
||||
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
||||
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -584,13 +812,23 @@ unknown" rather than being rejected.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing |
|
||||
| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
|
||||
| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none |
|
||||
| `DELETE` | `/api/schedule/{id}` | Remove schedule entry |
|
||||
Each team keeps its own rota, so two teams can have two different people on call
|
||||
on the same day. The person taking a shift has to be in the team — paging
|
||||
somebody who cannot open the incident is worse than paging nobody.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/teams/{teamID}/schedule` | **owner** | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing |
|
||||
| `GET` | `/api/teams/{teamID}/schedule` | member | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
|
||||
| `DELETE` | `/api/teams/{teamID}/schedule/{id}` | **owner** | Remove schedule entry |
|
||||
| `GET` | `/api/schedule/current` | any | Who is on call today (UTC) in **every** team the caller is in — one entry per team, `[]` when nobody anywhere |
|
||||
|
||||
### Statistics
|
||||
|
||||
Every figure counts the caller's own teams only: a report that counted other
|
||||
teams' incidents would leak their volume, and their alert names through the
|
||||
top-alerts list, and would not be a number about the reader's work anyway.
|
||||
|
||||
All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD`, and exclude archived rows to match the default list views. Alert stats filter on `received_at`; incident stats filter on `triggered_at`.
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -607,6 +845,81 @@ averages over incidents that have actually been acknowledged or resolved, and ar
|
||||
|
||||
---
|
||||
|
||||
## Upgrading to teams
|
||||
|
||||
Everything that existed before teams moves into one team called **Default**, and
|
||||
every existing user becomes an owner of it. The upgrade is a no-op for the
|
||||
people using it: the same queue, the same schedule, the same incidents, with a
|
||||
name on them.
|
||||
|
||||
What changes, and will need attention:
|
||||
|
||||
- **Alert ingestion moved.** Mint a key with
|
||||
`POST /api/teams/{teamID}/integrations` and point Alertmanager at the URL it
|
||||
returns. In v0.12.0 the old `POST /api/alertmanager/webhook` still worked,
|
||||
deprecated, routing everything to the oldest team; **v0.13.0 removes it**, so
|
||||
upgrade straight from v0.11.x to v0.13.0 only after the senders are moved.
|
||||
- **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and
|
||||
editing the rota is now an owner's job. `GET /api/schedule/current` stayed
|
||||
where it was but now returns an **array** — one entry per team with somebody
|
||||
on call — instead of a single object or a 404. This is a breaking API change
|
||||
for anything that reads it, terdut-tui included.
|
||||
- **Uniqueness is per team now.** Two teams can legitimately see the same alert
|
||||
fingerprint, the same Alertmanager groupKey, and put somebody on call on the
|
||||
same date.
|
||||
|
||||
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
|
||||
`_SEVERITY` are no longer the setting; they are the default each existing team
|
||||
is seeded with at startup, after which an owner edits them per team through
|
||||
`PUT /api/teams/{teamID}/deadman` and a redeploy never overwrites that.
|
||||
|
||||
Nothing else about an incident changes, and incidents never move between teams:
|
||||
an alert belongs to whichever team's key it arrived on.
|
||||
|
||||
## Upgrading to roles
|
||||
|
||||
Before this release every authenticated caller could create and delete users,
|
||||
set anybody's password and mint anybody's API keys. That is now the
|
||||
administrator flag, and the migration **makes every existing user an
|
||||
administrator** — they already held those powers, so nobody's access changes on
|
||||
upgrade and demotion is a deliberate act afterwards. Promoting only the first
|
||||
user would have silently stripped the rest, and could leave an install whose
|
||||
only administrator is an account nobody has a password for.
|
||||
|
||||
Users created after the upgrade are not administrators. Hand the flag out with:
|
||||
|
||||
```bash
|
||||
curl -X PUT https://terdut.example.com/api/users/7/admin \
|
||||
-H "Authorization: Bearer $TERDUT_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"is_admin": true}'
|
||||
```
|
||||
|
||||
Nothing in the API changed shape, so terdut-tui needs no new version — but a
|
||||
non-administrator now gets `403` where a `200` used to come back.
|
||||
|
||||
## Upgrading from SQLite
|
||||
|
||||
Versions up to v0.10.2 stored everything in a SQLite file. From v0.11.1 the server needs
|
||||
`TERDUT_DB_DSN` and keeps nothing on disk.
|
||||
|
||||
The copy was done by `scripts/sqlite-to-postgres.go`, which **was deleted in v0.13.0** along
|
||||
with the SQLite driver it was the last user of. It is still in the history — check out the
|
||||
`v0.12.0` tag to get it:
|
||||
|
||||
```bash
|
||||
git show v0.12.0:scripts/sqlite-to-postgres.go > sqlite-to-postgres.go
|
||||
```
|
||||
|
||||
The cutover is ordered, and the server must not be running while the copy happens: stop the
|
||||
old version, let the new binary build the schema against an empty Postgres, run the script
|
||||
with `-sqlite` and `-dsn`, then start the new version for good. On Kubernetes step three runs
|
||||
as a Job with the same image against the PVC before it is removed.
|
||||
|
||||
The copy preserves every id, so incidents keep their numbers and the timeline, alert
|
||||
membership, outbox and ack tokens all still point where they did. It refuses a target that
|
||||
already has rows, so a second run cannot double-insert.
|
||||
|
||||
## Upgrading to incidents
|
||||
|
||||
The incidents release moves the workflow off alerts, which is a **breaking API
|
||||
@@ -657,7 +970,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.
|
||||
|
||||
@@ -4,8 +4,16 @@ description: A Helm chart for Terminal Duty — on-call alert management server
|
||||
type: application
|
||||
# These two are placeholders for a local `helm install ./charts/terdut-server`, not the
|
||||
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
|
||||
# publishes, so the chart version always equals the app version. Bumping them by hand
|
||||
# does nothing for a release and is not needed before tagging.
|
||||
# "latest" is honest here: it matches image.tag in values.yaml.
|
||||
version: 0.9.0
|
||||
appVersion: "latest"
|
||||
# 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 -- `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.13.0
|
||||
appVersion: "v0.13.0"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -11,9 +11,28 @@ image:
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
storage:
|
||||
size: 1Gi
|
||||
storageClass: synology-iscsi
|
||||
# Postgres connection. The chart provisions no database; it expects one to exist.
|
||||
database:
|
||||
# Required. A DSN with no password in it:
|
||||
# postgres://terdut@terdut-postgres:5432/terdut?sslmode=require
|
||||
#
|
||||
# The password is deliberately a separate setting. pgx falls back to libpq's
|
||||
# environment variables for anything the DSN omits, so PGPASSWORD supplies it
|
||||
# without the credential appearing in values, in the rendered manifest, or in
|
||||
# `kubectl describe pod`.
|
||||
dsn: ""
|
||||
# Where PGPASSWORD comes from. With the Zalando postgres operator this is the
|
||||
# Secret it generates for the role — `<user>.<cluster>.credentials.postgresql.acid.zalan.do`,
|
||||
# whose keys are `username` and `password` — so a from-scratch rebuild mints a
|
||||
# new password and the server picks it up with nothing to keep in sync.
|
||||
#
|
||||
# Read at process start only: rotating the password needs a pod restart.
|
||||
#
|
||||
# Leave name empty only if the DSN carries its own password, which puts it in
|
||||
# the manifest.
|
||||
passwordSecret:
|
||||
name: ""
|
||||
key: password
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -88,17 +107,10 @@ notify:
|
||||
name: ""
|
||||
key: token
|
||||
|
||||
# The server image is FROM scratch — just the binary, with no shell, no sqlite3
|
||||
# and no python — so a k8up backupcommand cannot run in the app container. This
|
||||
# idle sidecar shares the data volume and is selected with
|
||||
# k8up.io/backupcommand-container. Only the stdlib sqlite3 module is used, so any
|
||||
# python image works.
|
||||
backupSidecar:
|
||||
enabled: true
|
||||
image:
|
||||
repository: python
|
||||
tag: "3.13-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
# Backups are no longer this chart's business. The SQLite database lived on a PVC
|
||||
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
|
||||
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
|
||||
# pg_dump annotation on the database pod itself.
|
||||
|
||||
bootstrap:
|
||||
enabled: true
|
||||
|
||||
+16
-3
@@ -18,7 +18,7 @@ var version = "dev"
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
database, err := db.Open(cfg.DBPath)
|
||||
database, err := db.Open(cfg.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
@@ -36,9 +36,22 @@ func main() {
|
||||
RepeatEvery: cfg.NotifyRepeat,
|
||||
}
|
||||
|
||||
// Dead man's switches live per team now. The environment variables are the
|
||||
// defaults a team starts from: every team without a configuration of its
|
||||
// own gets one from them here, and an owner's later edit is never
|
||||
// overwritten by a redeploy.
|
||||
deadman := api.ParseDeadmanConfig(cfg.DeadmanMatchers, cfg.DeadmanTimeout, cfg.DeadmanSeverity)
|
||||
if err := api.SeedDeadmanConfigs(context.Background(), database, deadman); err != nil {
|
||||
log.Fatalf("seed dead man's switch defaults: %v", err)
|
||||
}
|
||||
|
||||
router := api.NewRouter(database, notify, deadman)
|
||||
// The behaviour knobs move into the database on first start, after which an
|
||||
// administrator owns them and a redeploy leaves them alone.
|
||||
if err := api.SeedSettings(context.Background(), database, cfg); err != nil {
|
||||
log.Fatalf("seed settings: %v", err)
|
||||
}
|
||||
|
||||
router := api.NewRouter(database, notify, cfg)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -51,7 +64,7 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, deadman, notify)
|
||||
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notify)
|
||||
go api.StartNotifier(ctx, database, notify)
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -4,17 +4,15 @@ go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
modernc.org/sqlite v1.50.1
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,53 +1,32 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bootstrap user is an administrator; everybody it creates afterwards is
|
||||
// not. These tests are about the line between them.
|
||||
|
||||
// id64 spells an id into a path segment.
|
||||
func id64(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// member creates an ordinary user and an API key for it, and returns a caller
|
||||
// that authenticates as them. Minting the key goes through the admin's own
|
||||
// credentials, which is how a real install hands one out.
|
||||
func member(t *testing.T, s *ts, username string) (id int64, call func(method, path string, body any) *http.Response) {
|
||||
t.Helper()
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": username, "email": username + "@test.com"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create %s: %d", username, resp.StatusCode)
|
||||
}
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
decode(t, resp, &user)
|
||||
if user.IsAdmin {
|
||||
t.Fatalf("a created user must not be an administrator")
|
||||
}
|
||||
|
||||
// Into the default team as a plain member: being in a team is what lets
|
||||
// somebody work its incidents, and is separate from administering accounts.
|
||||
resp = s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("add %s to the team: %d", username, resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("mint key for %s: %d", username, resp.StatusCode)
|
||||
}
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, resp, &key)
|
||||
|
||||
return user.ID, func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the release: a user who is not an administrator cannot
|
||||
// manage other people's accounts. Every one of these was open to any
|
||||
// authenticated caller before.
|
||||
func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "member")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{"create a user", http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "sneaky", "email": "sneaky@test.com"}},
|
||||
{"delete the admin", http.MethodDelete, "/api/users/1", nil},
|
||||
{"grant themselves admin", http.MethodPut, "/api/users/" + id64(memberID) + "/admin",
|
||||
map[string]bool{"is_admin": true}},
|
||||
{"set the admin's password", http.MethodPut, "/api/users/1/password",
|
||||
map[string]string{"password": "hunter2-hunter2"}},
|
||||
{"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "borrowed"}},
|
||||
{"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify",
|
||||
map[string]string{"ntfy_topic": "attacker-topic"}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
resp := call(c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Being refused other people's accounts must not cost a user their own.
|
||||
func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "member")
|
||||
self := "/api/users/" + id64(memberID)
|
||||
|
||||
resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("own notify target: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("own password: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An API key carries exactly the rights of its owner, so minting your own
|
||||
// is no more than signing in again.
|
||||
resp = call(http.MethodPost, self+"/api-keys", map[string]string{"name": "laptop"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("own API key: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the queue still has to be able to name people.
|
||||
resp = call(http.MethodGet, "/api/users", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("list users: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Incident work is everybody's job; none of it is administration.
|
||||
func TestAdmin_MemberCanWorkIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, call := member(t, s, "responder")
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-admin", "DiskFull", "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"list", http.MethodGet, "/api/incidents"},
|
||||
{"acknowledge", http.MethodPost, "/api/incidents/1/acknowledge"},
|
||||
{"resolve", http.MethodPost, "/api/incidents/1/resolve"},
|
||||
} {
|
||||
resp := call(c.method, c.path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s: expected 200, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An install must never be left with nobody who can administer it.
|
||||
func TestAdmin_LastAdministratorIsProtected(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/users/1/admin", map[string]bool{"is_admin": false})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("self-demotion: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodDelete, "/api/users/1", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("deleting yourself: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// With a second administrator the first may stand down, but not while they
|
||||
// are the only one — which is the same rule from the other side.
|
||||
otherID, _ := member(t, s, "second")
|
||||
resp = s.req(t, http.MethodPut, "/api/users/"+id64(otherID)+"/admin", map[string]bool{"is_admin": true})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("granting admin: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodDelete, "/api/users/"+id64(otherID), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("deleting the second admin: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A promoted user gets the powers with the flag, and loses them with it.
|
||||
func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "promotee")
|
||||
admin := "/api/users/" + id64(memberID) + "/admin"
|
||||
|
||||
resp := call(http.MethodPost, "/api/users", map[string]string{"username": "a", "email": "a@test.com"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("before the grant: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": true})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("grant: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "b", "email": "b@test.com"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("after the grant: expected 201, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": false})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "c", "email": "c@test.com"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("after the revoke: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var me struct {
|
||||
User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/me", nil), &me)
|
||||
if !me.User.IsAdmin {
|
||||
t.Error("the bootstrap user should be an administrator")
|
||||
}
|
||||
|
||||
_, call := member(t, s, "plain")
|
||||
var theirs struct {
|
||||
User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
}
|
||||
decode(t, call(http.MethodGet, "/api/me", nil), &theirs)
|
||||
if theirs.User.IsAdmin {
|
||||
t.Error("a created user should not be an administrator")
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Values for alerts.resolution_source, recording why an alert left the firing
|
||||
@@ -70,8 +73,28 @@ type ingested struct {
|
||||
deadman bool
|
||||
}
|
||||
|
||||
func handleAlertmanagerWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.HandlerFunc {
|
||||
// handleIntegrationWebhook receives alerts on a team's own integration key.
|
||||
// The key in the path is both the credential and the routing: it says who may
|
||||
// post, and which team the alerts belong to.
|
||||
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnknownIntegration) {
|
||||
// 401 and not 404: the path is real, the key is not, and a
|
||||
// sender misconfigured this way should say so in its own logs
|
||||
// rather than believe it is delivering.
|
||||
respond(w, http.StatusUnauthorized, errResp("unknown integration key"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
receiveWebhook(w, r, db, notify, teamID)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
|
||||
var payload amPayload
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||
@@ -81,25 +104,32 @@ func handleAlertmanagerWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanC
|
||||
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
||||
// we failed to store is more useful than an error it cannot act on — so
|
||||
// failures are logged, not surfaced.
|
||||
if err := ingest(r.Context(), db, notify, deadman, payload); err != nil {
|
||||
log.Printf("webhook ingest (group %q): %v", payload.GroupKey, err)
|
||||
if err := ingest(r.Context(), db, notify, teamID, payload); err != nil {
|
||||
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ingest stores a payload's alerts and reconciles the incident for its group.
|
||||
// The whole payload is one transaction: an incident that opened but whose alerts
|
||||
// failed to link would be a work item nobody could act on.
|
||||
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman DeadmanConfig, payload amPayload) error {
|
||||
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64, payload amPayload) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
accepted, err := upsertAlerts(ctx, tx, deadman, payload.Alerts)
|
||||
// Which arriving alerts are heartbeats is the team's own answer, read
|
||||
// inside the transaction so an owner editing it mid-payload cannot split
|
||||
// one webhook across two interpretations.
|
||||
deadman, err := deadmanConfigForTeam(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -108,7 +138,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma
|
||||
// resolution cascade are recomputed once per incident at the end.
|
||||
touched := map[int64]bool{}
|
||||
|
||||
incidentID, err := incidentForGroup(ctx, tx, notify, payload, accepted)
|
||||
incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,7 +186,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma
|
||||
|
||||
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
||||
// the ordering guard rejected are left out entirely.
|
||||
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts []amAlert) ([]ingested, error) {
|
||||
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) {
|
||||
now := time.Now().Unix()
|
||||
accepted := make([]ingested, 0, len(alerts))
|
||||
|
||||
@@ -171,7 +201,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
||||
var prevStartsAt int64
|
||||
existed := true
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
"SELECT status, starts_at FROM alerts WHERE fingerprint = ?", a.Fingerprint,
|
||||
"SELECT status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
|
||||
teamID, a.Fingerprint,
|
||||
).Scan(&prevStatus, &prevStartsAt); {
|
||||
case err == sql.ErrNoRows:
|
||||
existed = false
|
||||
@@ -210,10 +241,10 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
||||
// undone by a stale retry.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO alerts
|
||||
(fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
||||
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
||||
generator_url, received_at, resolution_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
labels = excluded.labels,
|
||||
annotations = excluded.annotations,
|
||||
@@ -233,7 +264,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
||||
OR (excluded.starts_at = alerts.starts_at
|
||||
AND (alerts.resolution_source = '`+resolutionDeadman+`'
|
||||
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
|
||||
a.Fingerprint, name, a.Status,
|
||||
teamID, a.Fingerprint, name, a.Status,
|
||||
string(labelsJSON), string(annotationsJSON),
|
||||
a.StartsAt.Unix(), endsAtUnix,
|
||||
a.GeneratorURL, now, resolutionSource,
|
||||
@@ -245,7 +276,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
||||
var curStatus string
|
||||
var curStartsAt int64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT id, status, starts_at FROM alerts WHERE fingerprint = ?", a.Fingerprint,
|
||||
"SELECT id, status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
|
||||
teamID, a.Fingerprint,
|
||||
).Scan(&id, &curStatus, &curStartsAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -284,7 +316,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
||||
// Heartbeats do not count as anything here. A group of nothing but dead man's
|
||||
// switch alerts opens no incident at all, and a mixed group gets an incident for
|
||||
// its real alerts only.
|
||||
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payload amPayload, accepted []ingested) (int64, error) {
|
||||
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, teamID int64, payload amPayload, accepted []ingested) (int64, error) {
|
||||
var firstName string
|
||||
anyFiring, anyNew := false, false
|
||||
for _, a := range accepted {
|
||||
@@ -315,7 +347,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
|
||||
|
||||
var id int64
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
"SELECT id FROM incidents WHERE group_key = ? AND resolved_at IS NULL", groupKey,
|
||||
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
|
||||
teamID, groupKey,
|
||||
).Scan(&id); {
|
||||
case err == nil:
|
||||
return id, nil
|
||||
@@ -326,7 +359,7 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
|
||||
if !anyNew {
|
||||
return 0, nil
|
||||
}
|
||||
return openIncident(ctx, tx, notify, groupKey,
|
||||
return openIncident(ctx, tx, notify, teamID, groupKey,
|
||||
incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil)
|
||||
}
|
||||
|
||||
@@ -338,8 +371,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
|
||||
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
||||
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||
// one because its incidents have no members to derive it from.
|
||||
func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||
onCall, err := currentOnCall(ctx, q)
|
||||
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||
onCall, err := currentOnCall(ctx, q, teamID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -349,15 +382,13 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
|
||||
labelsJSON = []byte("{}")
|
||||
}
|
||||
|
||||
res, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
||||
VALUES (?, ?, ?, 'triggered', ?, ?, ?)`,
|
||||
groupKey, title, string(labelsJSON), severity,
|
||||
time.Now().Unix(), onCall)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
var id int64
|
||||
err = q.QueryRowContext(ctx, `
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
|
||||
RETURNING id`,
|
||||
teamID, groupKey, title, string(labelsJSON), severity,
|
||||
time.Now().Unix(), onCall).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -372,12 +403,18 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Queue the page, but do not send it here: this runs inside a transaction on
|
||||
// a single-connection pool, so an HTTP call would hold up every other
|
||||
// request. The notifier picks the row up within a tick.
|
||||
// Queue the page, but do not send it here: this runs inside the webhook's
|
||||
// transaction, and an HTTP call would hold a connection open across a
|
||||
// network round trip. The notifier picks the row up within a tick.
|
||||
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// And start the escalation clock, if the team keeps one. In the same
|
||||
// transaction, so an incident is never briefly open with nobody counting.
|
||||
if err := startEscalation(ctx, q, id, teamID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -385,8 +422,9 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
|
||||
// first time. Re-sends of an already-linked alert are silent.
|
||||
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO incident_alerts (incident_id, alert_id, added_at)
|
||||
VALUES (?, ?, ?)`, incidentID, alertID, time.Now().Unix())
|
||||
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (incident_id, alert_id) DO NOTHING`, incidentID, alertID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+24
-21
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// incident_alerts rather than as a column here, because one alert row is reused
|
||||
// across occurrences and belongs to a different incident each time.
|
||||
const alertSelectFrom = `
|
||||
SELECT a.id, a.fingerprint, a.name, a.status,
|
||||
SELECT a.id, a.team_id, t.name, a.fingerprint, a.name, a.status,
|
||||
a.labels, a.annotations,
|
||||
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
||||
(SELECT ia.incident_id
|
||||
@@ -29,22 +29,28 @@ const alertSelectFrom = `
|
||||
ORDER BY i.triggered_at DESC, i.id DESC
|
||||
LIMIT 1),
|
||||
a.resolution_source, a.archived_at
|
||||
FROM alerts a`
|
||||
FROM alerts a
|
||||
JOIN teams t ON t.id = a.team_id`
|
||||
|
||||
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
args := &sqlArgs{}
|
||||
|
||||
where = append(where, "a.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
|
||||
if team := q.Get("team_id"); team != "" {
|
||||
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
|
||||
where = append(where, "a.team_id = "+args.add(n))
|
||||
}
|
||||
}
|
||||
|
||||
if status := q.Get("status"); status != "" {
|
||||
where = append(where, "a.status = ?")
|
||||
args = append(args, status)
|
||||
where = append(where, "a.status = "+args.add(status))
|
||||
}
|
||||
if name := q.Get("name"); name != "" {
|
||||
where = append(where, "a.name = ?")
|
||||
args = append(args, name)
|
||||
where = append(where, "a.name = "+args.add(name))
|
||||
}
|
||||
if archived := q.Get("archived"); archived == "true" {
|
||||
where = append(where, "a.archived_at IS NOT NULL")
|
||||
@@ -53,21 +59,18 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
if incidentID := q.Get("incident_id"); incidentID != "" {
|
||||
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
|
||||
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = ?)")
|
||||
args = append(args, n)
|
||||
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = "+args.add(n)+")")
|
||||
}
|
||||
}
|
||||
|
||||
if from := q.Get("from"); from != "" {
|
||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||
where = append(where, "a.received_at >= ?")
|
||||
args = append(args, t.UTC().Unix())
|
||||
where = append(where, "a.received_at >= "+args.add(t.UTC().Unix()))
|
||||
}
|
||||
}
|
||||
if to := q.Get("to"); to != "" {
|
||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||
where = append(where, "a.received_at < ?")
|
||||
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
||||
where = append(where, "a.received_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +85,10 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
||||
if len(where) > 0 {
|
||||
clause = strings.Join(where, " AND ")
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
|
||||
args...)
|
||||
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT %s", alertSelectFrom, clause, args.add(limit)),
|
||||
args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -113,7 +115,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
||||
return
|
||||
}
|
||||
a, err := fetchAlert(r.Context(), db, id)
|
||||
a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context()))
|
||||
if err == sql.ErrNoRows {
|
||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
||||
return
|
||||
@@ -127,8 +129,9 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// fetchAlert loads a single alert by ID using the shared query.
|
||||
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
|
||||
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
|
||||
func fetchAlert(ctx context.Context, db *sql.DB, id int64, teamIDs []int64) (models.Alert, error) {
|
||||
return scanAlert(db.QueryRowContext(ctx,
|
||||
alertSelectFrom+" WHERE a.id = $1 AND a.team_id = ANY($2)", id, teamIDs))
|
||||
}
|
||||
|
||||
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||
@@ -143,7 +146,7 @@ func scanAlert(s scanner) (models.Alert, error) {
|
||||
var endsAtUnix, archivedAtUnix *int64
|
||||
|
||||
if err := s.Scan(
|
||||
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
|
||||
&a.ID, &a.TeamID, &a.TeamName, &a.Fingerprint, &a.Name, &a.Status,
|
||||
&labelsJSON, &annotationsJSON,
|
||||
&startsAtUnix, &endsAtUnix,
|
||||
&a.GeneratorURL, &receivedAtUnix,
|
||||
|
||||
+96
-41
@@ -9,11 +9,12 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
||||
@@ -21,12 +22,16 @@ import (
|
||||
type ts struct {
|
||||
*httptest.Server
|
||||
key string
|
||||
// ingestKey is an integration key for the default team: the only way in
|
||||
// since the unauthenticated webhook was removed, so the tests exercise the
|
||||
// same path production does.
|
||||
ingestKey string
|
||||
db *sql.DB
|
||||
notify api.NotifyConfig
|
||||
deadman api.DeadmanConfig
|
||||
}
|
||||
|
||||
// newTS builds a server over a fresh in-memory database. Notifications are off
|
||||
// newTS builds a server over a fresh database. Notifications are off
|
||||
// unless a NotifyConfig is passed, so tests that predate them are unaffected.
|
||||
// Dead man's switches are off too — see newDeadmanTS.
|
||||
func newTS(t *testing.T, notify ...api.NotifyConfig) *ts {
|
||||
@@ -38,7 +43,7 @@ func newTS(t *testing.T, notify ...api.NotifyConfig) *ts {
|
||||
return newDeadmanTS(t, api.DeadmanConfig{}, cfg)
|
||||
}
|
||||
|
||||
// newDeadmanTS is newTS with dead man's switch handling configured.
|
||||
// newDeadmanTS is newTS with the default team's dead man's switches configured.
|
||||
func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyConfig) *ts {
|
||||
t.Helper()
|
||||
var cfg api.NotifyConfig
|
||||
@@ -46,15 +51,9 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
||||
cfg = notify[0]
|
||||
}
|
||||
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(api.NewRouter(database, cfg, deadman))
|
||||
t.Cleanup(func() { srv.Close(); database.Close() })
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
||||
@@ -69,7 +68,48 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
key := result["api_key"].(map[string]any)["key"].(string)
|
||||
|
||||
return &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
||||
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
if integration.Key == "" {
|
||||
t.Fatal("no integration key was returned")
|
||||
}
|
||||
s.ingestKey = integration.Key
|
||||
|
||||
// Dead man's switches belong to a team now, so a test that wants them
|
||||
// configures the default team the way an owner would.
|
||||
if deadman.Timeout > 0 {
|
||||
setTeamDeadman(t, s, deadman)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setTeamDeadman configures the default team's switches over the API, rendering
|
||||
// the matchers back into the string form the endpoint takes.
|
||||
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
|
||||
t.Helper()
|
||||
matchers := make([]string, 0, len(cfg.Matchers))
|
||||
for _, m := range cfg.Matchers {
|
||||
parts := []string{"alertname=" + m.Name}
|
||||
for k, v := range m.Labels {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
sort.Strings(parts[1:])
|
||||
matchers = append(matchers, strings.Join(parts, ","))
|
||||
}
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
||||
"matchers": strings.Join(matchers, "; "),
|
||||
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
||||
"severity": cfg.Severity,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("configure the team's dead man's switches: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// exec runs a statement against the test database.
|
||||
@@ -84,7 +124,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 +136,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 +149,7 @@ func (s *ts) alertEndsAt(t *testing.T, fingerprint string) *int64 {
|
||||
t.Helper()
|
||||
var endsAt *int64
|
||||
if err := s.db.QueryRow(
|
||||
"SELECT ends_at FROM alerts WHERE fingerprint = ?", fingerprint).Scan(&endsAt); err != nil {
|
||||
"SELECT ends_at FROM alerts WHERE fingerprint = $1", fingerprint).Scan(&endsAt); err != nil {
|
||||
t.Fatalf("read ends_at %s: %v", fingerprint, err)
|
||||
}
|
||||
return endsAt
|
||||
@@ -208,7 +248,8 @@ func postWebhook(t *testing.T, s *ts, alerts []map[string]any, groupKey ...strin
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data))
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post webhook: %v", err)
|
||||
}
|
||||
@@ -283,14 +324,14 @@ func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) {
|
||||
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
first := s.req(t, http.MethodPost, "/api/schedule",
|
||||
first := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||
if first.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first assignment returned %d", first.StatusCode)
|
||||
}
|
||||
first.Body.Close()
|
||||
|
||||
second := s.req(t, http.MethodPost, "/api/schedule",
|
||||
second := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||
if second.StatusCode != http.StatusConflict {
|
||||
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
||||
@@ -302,11 +343,11 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// Claim 2026-06-10 first.
|
||||
s.req(t, http.MethodPost, "/api/schedule",
|
||||
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close()
|
||||
|
||||
// Try to assign two dates in one request where the second conflicts.
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
||||
@@ -314,7 +355,7 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
|
||||
// 2026-06-09 must NOT have been committed (transaction rolled back).
|
||||
listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil)
|
||||
listResp := s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-09&to=2026-06-09", nil)
|
||||
var entries []any
|
||||
decode(t, listResp, &entries)
|
||||
if len(entries) != 0 {
|
||||
@@ -328,21 +369,35 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
||||
|
||||
// addUser creates a second person to hand a shift to. The bootstrap user is
|
||||
// admin, id 1.
|
||||
// addUser creates a user and puts them in the default team, because a user who
|
||||
// is in no team can be paged by nobody and take no shift — which is the rule
|
||||
// these tests exercise around, not the one they are testing.
|
||||
func addUser(t *testing.T, s *ts, username string) {
|
||||
t.Helper()
|
||||
resp := s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]any{"username": username, "email": username + "@test.com"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("create user returned %d", resp.StatusCode)
|
||||
}
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, resp, &user)
|
||||
|
||||
member := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"})
|
||||
defer member.Body.Close()
|
||||
if member.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("add %s to the team returned %d", username, member.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleHolder reports who is on call for one date, or "" for nobody.
|
||||
func scheduleHolder(t *testing.T, s *ts, date string) string {
|
||||
t.Helper()
|
||||
var entries []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/schedule?from="+date+"&to="+date, nil), &entries)
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from="+date+"&to="+date, nil), &entries)
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -354,10 +409,10 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
|
||||
s := newTS(t)
|
||||
addUser(t, s, "alex")
|
||||
|
||||
s.req(t, http.MethodPost, "/api/schedule",
|
||||
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}, "replace": true})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected replace to succeed, got %d", resp.StatusCode)
|
||||
@@ -371,7 +426,7 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
|
||||
// One row, not two: two entries for a date would mean two people believing
|
||||
// they are on call for it.
|
||||
var entries []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected exactly one entry for the date, got %d", len(entries))
|
||||
}
|
||||
@@ -383,11 +438,11 @@ func TestSchedule_ReplaceMixedWeek(t *testing.T) {
|
||||
s := newTS(t)
|
||||
addUser(t, s, "alex")
|
||||
|
||||
s.req(t, http.MethodPost, "/api/schedule",
|
||||
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-02", "2026-06-04"}}).Body.Close()
|
||||
|
||||
week := []string{"2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"}
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 2, "dates": week, "replace": true})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected the mixed week to succeed, got %d", resp.StatusCode)
|
||||
@@ -406,10 +461,10 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) {
|
||||
s := newTS(t)
|
||||
addUser(t, s, "alex")
|
||||
|
||||
s.req(t, http.MethodPost, "/api/schedule",
|
||||
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409 without replace, got %d", resp.StatusCode)
|
||||
@@ -427,7 +482,7 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) {
|
||||
func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01", "2026-06-01"}, "replace": true})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected a repeated date to be accepted under replace, got %d", resp.StatusCode)
|
||||
@@ -435,7 +490,7 @@ func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
|
||||
var entries []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected one entry for the repeated date, got %d", len(entries))
|
||||
}
|
||||
@@ -505,7 +560,7 @@ func TestArchive_AlertListFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Let the sweeper archive it: ends_at is already well past archiveAfter.
|
||||
api.Sweep(context.Background(), s.db, time.Hour, 6*time.Hour, s.deadman, s.notify)
|
||||
api.Sweep(context.Background(), s.db, time.Hour, 6*time.Hour, s.notify)
|
||||
|
||||
// 3. Default list excludes it.
|
||||
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||
@@ -546,7 +601,7 @@ func postAlert(t *testing.T, s *ts, fingerprint, status, startsAt, endsAt string
|
||||
|
||||
func sweep(t *testing.T, s *ts, staleAfter time.Duration) {
|
||||
t.Helper()
|
||||
api.Sweep(context.Background(), s.db, noArchive, staleAfter, s.deadman, s.notify)
|
||||
api.Sweep(context.Background(), s.db, noArchive, staleAfter, s.notify)
|
||||
}
|
||||
|
||||
// A firing alert Alertmanager stopped refreshing is resolved via the
|
||||
@@ -556,7 +611,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 +708,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 +769,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 +823,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 +851,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
|
||||
|
||||
|
||||
+31
-25
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -19,15 +19,19 @@ const (
|
||||
|
||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||
// immediate pass so a restart reconciles state right away.
|
||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, deadman DeadmanConfig, notify NotifyConfig) {
|
||||
// archiveAfter and staleAfter are the values the server started with. They are
|
||||
// the fallback, not the setting: each pass reads the current value from the
|
||||
// settings table, so an administrator's change takes effect on the next tick
|
||||
// instead of at the next restart.
|
||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, deadman, notify)
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, deadman, notify)
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -44,13 +48,18 @@ func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter tim
|
||||
// touch: a heartbeat answers to its own, much tighter, timeout, and the generic
|
||||
// staleness rules would otherwise resolve it as 'expiry' long before that.
|
||||
// Exported so tests can drive a pass without waiting on the ticker.
|
||||
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, deadman DeadmanConfig, notify NotifyConfig) {
|
||||
heartbeats := sweepDeadman(ctx, db, deadman, notify)
|
||||
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||
settings := NewSettings(db)
|
||||
staleAfter = settings.Duration(ctx, SettingStaleAfter, staleAfter)
|
||||
archiveAfter = settings.Duration(ctx, SettingArchiveAfter, archiveAfter)
|
||||
|
||||
heartbeats := sweepDeadman(ctx, db, notify)
|
||||
expireStale(ctx, db, staleAfter, heartbeats)
|
||||
resolveSettledIncidents(ctx, db)
|
||||
archiveResolved(ctx, db, archiveAfter)
|
||||
archiveResolvedIncidents(ctx, db, archiveAfter)
|
||||
purgeAckTokens(ctx, db)
|
||||
purgeSessions(ctx, db)
|
||||
}
|
||||
|
||||
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
|
||||
@@ -90,17 +99,18 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
return
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, resolutionExpiry)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
args := &sqlArgs{}
|
||||
source := args.add(resolutionExpiry)
|
||||
idList := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
idList[i] = id
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE alerts
|
||||
SET status = 'resolved',
|
||||
resolution_source = ?,
|
||||
ends_at = COALESCE(ends_at, unixepoch())
|
||||
WHERE id IN (`+placeholders(len(ids))+`)`, args...); err != nil {
|
||||
resolution_source = `+source+`,
|
||||
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
||||
WHERE id IN (`+args.addList(idList)+`)`, args.all()...); err != nil {
|
||||
log.Printf("sweeper: expire stale: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -123,14 +133,15 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
}
|
||||
|
||||
// staleAlertIDs reads the ids in one go and closes the cursor before the caller
|
||||
// writes: the pool is limited to a single connection, so an open read would
|
||||
// block the update behind it.
|
||||
// writes. Under SQLite's single connection an open read would have blocked the
|
||||
// update outright; with a pool it is no longer a deadlock, but reading the set
|
||||
// first still keeps the write off a cursor the same transaction is walking.
|
||||
func staleAlertIDs(ctx context.Context, db *sql.DB, now time.Time, staleAfter time.Duration) ([]int64, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id FROM alerts
|
||||
WHERE status = 'firing'
|
||||
AND archived_at IS NULL
|
||||
AND ((ends_at IS NOT NULL AND ends_at < ?) OR received_at < ?)`,
|
||||
AND ((ends_at IS NOT NULL AND ends_at < $1) OR received_at < $2)`,
|
||||
now.Add(-expiryGrace).Unix(), now.Add(-staleAfter).Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -205,10 +216,10 @@ func settledIncidentIDs(ctx context.Context, db *sql.DB) ([]int64, error) {
|
||||
func archiveResolved(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-archiveAfter).Unix()
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE alerts SET archived_at = unixepoch()
|
||||
`UPDATE alerts SET archived_at = `+nowEpoch+`
|
||||
WHERE status = 'resolved'
|
||||
AND archived_at IS NULL
|
||||
AND COALESCE(ends_at, received_at) < ?`, cutoff)
|
||||
AND COALESCE(ends_at, received_at) < $1`, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("archiver: %v", err)
|
||||
return
|
||||
@@ -222,10 +233,10 @@ func archiveResolved(ctx context.Context, db *sql.DB, archiveAfter time.Duration
|
||||
func archiveResolvedIncidents(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-archiveAfter).Unix()
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE incidents SET archived_at = unixepoch()
|
||||
`UPDATE incidents SET archived_at = `+nowEpoch+`
|
||||
WHERE resolved_at IS NOT NULL
|
||||
AND archived_at IS NULL
|
||||
AND resolved_at < ?`, cutoff)
|
||||
AND resolved_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("archiver: incidents: %v", err)
|
||||
return
|
||||
@@ -234,8 +245,3 @@ func archiveResolvedIncidents(ctx context.Context, db *sql.DB, archiveAfter time
|
||||
log.Printf("archiver: archived %d resolved incident(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// placeholders builds "?, ?, …" for an IN clause of n values.
|
||||
func placeholders(n int) string {
|
||||
return strings.TrimSuffix(strings.Repeat("?, ", n), ", ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
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 is
|
||||
// restricted to administrators: it hands over an account outright, without
|
||||
// knowing the password it replaces.
|
||||
//
|
||||
// Every other session of the target is ended: a password change is what you
|
||||
// do when you think someone else is signed in.
|
||||
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
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, 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)
|
||||
}
|
||||
}
|
||||
@@ -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.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{}, testConfig()))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
||||
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", strings.NewReader(body))
|
||||
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)
|
||||
}
|
||||
}
|
||||
+152
-28
@@ -171,6 +171,7 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
||||
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
|
||||
type deadmanAlert struct {
|
||||
id int64
|
||||
teamID int64
|
||||
fingerprint string
|
||||
labels map[string]string
|
||||
matcher DeadmanMatcher
|
||||
@@ -188,27 +189,33 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
|
||||
// It returns the ids of the alerts it owns, because the generic staleness
|
||||
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
|
||||
// a heartbeat long before its own, much tighter, timeout ever fired.
|
||||
func sweepDeadman(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig) map[int64]bool {
|
||||
// Each team is swept against its own configuration: its own matchers, its own
|
||||
// timeout, its own severity. A team watching nothing is skipped entirely, which
|
||||
// is most of them.
|
||||
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
|
||||
owned := map[int64]bool{}
|
||||
if !cfg.enabled() {
|
||||
return owned
|
||||
}
|
||||
|
||||
switches, err := deadmanAlerts(ctx, db, cfg)
|
||||
configs, err := deadmanConfigs(ctx, db)
|
||||
if err != nil {
|
||||
log.Printf("deadman: load switches: %v", err)
|
||||
log.Printf("deadman: load configs: %v", err)
|
||||
return owned
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for teamID, cfg := range configs {
|
||||
switches, err := deadmanAlerts(ctx, db, teamID, cfg)
|
||||
if err != nil {
|
||||
log.Printf("deadman: load switches for team %d: %v", teamID, err)
|
||||
continue
|
||||
}
|
||||
cutoff := now.Add(-cfg.Timeout).Unix()
|
||||
|
||||
for _, sw := range switches {
|
||||
owned[sw.id] = true
|
||||
|
||||
// An explicit resolved from Alertmanager is a stronger death signal than
|
||||
// mere absence: the sender is telling us the heartbeat stopped, so there
|
||||
// is nothing left to wait out.
|
||||
// An explicit resolved from Alertmanager is a stronger death signal
|
||||
// than mere absence: the sender is telling us the heartbeat
|
||||
// stopped, so there is nothing left to wait out.
|
||||
if sw.resolved || sw.receivedAt < cutoff {
|
||||
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
|
||||
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
|
||||
@@ -219,25 +226,29 @@ func sweepDeadman(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Not
|
||||
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
// 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.
|
||||
func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadmanAlert, error) {
|
||||
// in full before the caller writes, so the writes do not run against an open
|
||||
// cursor over the same table.
|
||||
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg 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
|
||||
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||
FROM alerts
|
||||
WHERE name IN (`+placeholders(len(names))+`)
|
||||
AND archived_at IS NULL`, args...)
|
||||
WHERE team_id = `+args.add(teamID)+`
|
||||
AND name IN (`+args.addList(nameList)+`)
|
||||
AND archived_at IS NULL`, args.all()...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -247,7 +258,7 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma
|
||||
for rows.Next() {
|
||||
var a deadmanAlert
|
||||
var labelsJSON, status string
|
||||
if err := rows.Scan(&a.id, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil {
|
||||
if err := rows.Scan(&a.id, &a.teamID, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
||||
@@ -277,9 +288,9 @@ 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 = ?`,
|
||||
sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
||||
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
||||
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||
return err
|
||||
}
|
||||
if open > 0 || sw.receivedAt <= lastTriggered {
|
||||
@@ -299,9 +310,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
|
||||
}
|
||||
}
|
||||
@@ -312,7 +323,9 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
||||
sev = &severity
|
||||
}
|
||||
|
||||
incidentID, err := openIncident(ctx, tx, notify, sw.groupKey(),
|
||||
// The incident opens in the team whose integration received the heartbeat:
|
||||
// the switch belongs to whoever is watching that source, not to the install.
|
||||
incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(),
|
||||
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -341,7 +354,8 @@ 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 team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
|
||||
sw.teamID, sw.groupKey()).Scan(&incidentID); {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil
|
||||
case err != nil:
|
||||
@@ -356,8 +370,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
|
||||
}
|
||||
@@ -376,3 +390,113 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
||||
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-team configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// deadmanConfigForTeam reads one team's switches. A team with no row, or with
|
||||
// nothing configured, gets a disabled config — which is the right answer rather
|
||||
// than an error: most teams watch no heartbeat at all.
|
||||
func deadmanConfigForTeam(ctx context.Context, q querier, teamID int64) (DeadmanConfig, error) {
|
||||
var matchers, severity string
|
||||
var timeout int64
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
||||
teamID).Scan(&matchers, &timeout, &severity)
|
||||
if err == sql.ErrNoRows {
|
||||
return DeadmanConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return DeadmanConfig{}, err
|
||||
}
|
||||
return parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity), nil
|
||||
}
|
||||
|
||||
// deadmanConfigs reads every team's switches in one query, for the sweeper.
|
||||
func deadmanConfigs(ctx context.Context, db *sql.DB) (map[int64]DeadmanConfig, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT team_id, matchers, timeout_seconds, severity FROM deadman_configs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[int64]DeadmanConfig{}
|
||||
for rows.Next() {
|
||||
var teamID, timeout int64
|
||||
var matchers, severity string
|
||||
if err := rows.Scan(&teamID, &matchers, &timeout, &severity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity)
|
||||
if cfg.enabled() {
|
||||
out[teamID] = cfg
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SeedDeadmanConfigs gives every team without a row the server's environment
|
||||
// configuration, so the install that upgrades into per-team switches keeps
|
||||
// watching exactly what it was watching before.
|
||||
//
|
||||
// Idempotent, and never overwrites: once a team has a row it owns its own
|
||||
// configuration, and a redeploy must not quietly put the environment's value
|
||||
// back over an owner's edit.
|
||||
//
|
||||
// A team created after startup gets no row and therefore watches nothing until
|
||||
// its owner says otherwise. That is deliberate: inheriting an install-wide
|
||||
// heartbeat would page a new team about a source it has never heard of, and a
|
||||
// switch nobody chose is the kind that gets muted rather than fixed.
|
||||
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
|
||||
matchers := make([]string, 0, len(cfg.Matchers))
|
||||
for _, m := range cfg.Matchers {
|
||||
parts := []string{"alertname=" + m.Name}
|
||||
for k, v := range m.Labels {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
sort.Strings(parts[1:])
|
||||
matchers = append(matchers, strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity)
|
||||
SELECT id, $1, $2, $3 FROM teams
|
||||
ON CONFLICT (team_id) DO NOTHING`,
|
||||
strings.Join(matchers, "; "), int64(cfg.Timeout.Seconds()), cfg.Severity)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseDeadmanQuietly is ParseDeadmanConfig without the startup logging: a
|
||||
// team's configuration is read on every sweep and every webhook, and logging it
|
||||
// each time would bury everything else.
|
||||
func parseDeadmanQuietly(matchers string, timeout time.Duration, severity string) DeadmanConfig {
|
||||
cfg := DeadmanConfig{Timeout: timeout, Severity: severity}
|
||||
for _, entry := range strings.Split(matchers, ";") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||
malformed := false
|
||||
for _, cond := range strings.Split(entry, ",") {
|
||||
k, v, ok := strings.Cut(cond, "=")
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if !ok || k == "" || v == "" {
|
||||
malformed = true
|
||||
break
|
||||
}
|
||||
if k == "alertname" {
|
||||
m.Name = v
|
||||
continue
|
||||
}
|
||||
m.Labels[k] = v
|
||||
}
|
||||
if malformed || m.Name == "" {
|
||||
continue
|
||||
}
|
||||
cfg.Matchers = append(cfg.Matchers, m)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -51,7 +52,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 +62,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 +76,7 @@ func incidentByGroup(t *testing.T, s *ts, groupKey string) (id int64, status, se
|
||||
t.Helper()
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, status, COALESCE(severity, ''), resolution_source
|
||||
FROM incidents WHERE group_key = ? ORDER BY id DESC LIMIT 1`,
|
||||
FROM incidents WHERE group_key = $1 ORDER BY id DESC LIMIT 1`,
|
||||
groupKey).Scan(&id, &status, &severity, &source)
|
||||
if err != nil {
|
||||
t.Fatalf("read incident for group %s: %v", groupKey, err)
|
||||
@@ -466,3 +470,123 @@ func TestDeadman_DisabledConfigIsInert(t *testing.T) {
|
||||
t.Errorf("expected the generic sweeper to own the alert, got %v", source)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-team configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Each team decides for itself what a heartbeat is. The same alert is a
|
||||
// heartbeat in one team and an ordinary problem in another.
|
||||
func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
watched := newTeam(t, s, "watched")
|
||||
unwatched := newTeam(t, s, "unwatched")
|
||||
|
||||
// Only the first team calls Watchdog a heartbeat.
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(watched.id)+"/deadman", map[string]any{
|
||||
"matchers": "alertname=Watchdog",
|
||||
"timeout_seconds": 3600,
|
||||
"severity": "critical",
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("configure the watched team: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
postToIntegration(t, s, watched.key, "fp-watched", "Watchdog")
|
||||
postToIntegration(t, s, unwatched.key, "fp-unwatched", "Watchdog")
|
||||
|
||||
// A heartbeat opens nothing where it is one; an ordinary alert opens an
|
||||
// incident where it is not.
|
||||
if got := len(list(t, watched.call(http.MethodGet, "/api/incidents", nil))); got != 0 {
|
||||
t.Errorf("the watched team's heartbeat opened %d incident(s), want 0", got)
|
||||
}
|
||||
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
|
||||
t.Errorf("the unwatched team's Watchdog opened %d incident(s), want 1", got)
|
||||
}
|
||||
|
||||
// Silence pages only the team that is watching.
|
||||
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||
time.Now().Add(-2*time.Hour).Unix(), "fp-watched")
|
||||
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||
time.Now().Add(-2*time.Hour).Unix(), "fp-unwatched")
|
||||
sweep(t, s, noArchive)
|
||||
|
||||
watchedIncidents := list(t, watched.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(watchedIncidents) != 1 {
|
||||
t.Fatalf("silence opened %d incident(s) for the watching team, want 1", len(watchedIncidents))
|
||||
}
|
||||
if title := watchedIncidents[0]["title"].(string); title != "No heartbeat from Watchdog" {
|
||||
t.Errorf("unexpected incident title %q", title)
|
||||
}
|
||||
if teamID := int64(watchedIncidents[0]["team_id"].(float64)); teamID != watched.id {
|
||||
t.Errorf("the incident opened in team %d, want %d", teamID, watched.id)
|
||||
}
|
||||
|
||||
// The unwatched team's alert went stale the ordinary way, so it has the one
|
||||
// incident it always had — not a second, dead man's switch one.
|
||||
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
|
||||
t.Errorf("the unwatched team ended with %d incident(s), want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration is an owner's to change and a member's to read, like the rest of
|
||||
// a team's settings.
|
||||
func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
// A plain member of that team.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
|
||||
s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut,
|
||||
s.URL+"/api/teams/"+id64(team.id)+"/deadman",
|
||||
strings.NewReader(`{"matchers":"alertname=Watchdog","timeout_seconds":60}`))
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman", nil)
|
||||
read.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
got, err := http.DefaultClient.Do(read)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
got.Body.Close()
|
||||
if got.StatusCode != http.StatusOK {
|
||||
t.Errorf("a member reading the switches: expected 200, got %d", got.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A matcher with no alertname watches nothing, silently, which is the failure
|
||||
// this feature exists to prevent — so it is refused at the door.
|
||||
func TestDeadman_UnusableMatchersAreRejected(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
||||
"matchers": "cluster=prod",
|
||||
"timeout_seconds": 900,
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for a matcher with no alertname, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// evEscalated records a rung of the ladder on the incident's timeline: which
|
||||
// level, and who it woke.
|
||||
const evEscalated = "escalated"
|
||||
|
||||
// escalationPolicy is a team's ladder, loaded whole. It is small — a handful of
|
||||
// levels with a few targets each — and every use needs all of it, so there is
|
||||
// no point reading it a level at a time.
|
||||
type escalationPolicy struct {
|
||||
teamID int64
|
||||
repeatCount int64
|
||||
fallbackTopic string
|
||||
levels []escalationLevel
|
||||
}
|
||||
|
||||
type escalationLevel struct {
|
||||
id int64
|
||||
position int64
|
||||
timeout time.Duration
|
||||
targets []escalationTarget
|
||||
}
|
||||
|
||||
type escalationTarget struct {
|
||||
kind string // "user" or "oncall"
|
||||
userID *int64
|
||||
}
|
||||
|
||||
// configured reports whether this team has anything to escalate through. A
|
||||
// policy row with no levels is the same as no policy: the team gets the
|
||||
// pre-escalation behaviour, which is reminders on the assignee's topic.
|
||||
func (p *escalationPolicy) configured() bool { return p != nil && len(p.levels) > 0 }
|
||||
|
||||
// level returns the level at a 1-based position.
|
||||
func (p *escalationPolicy) level(pos int64) (escalationLevel, bool) {
|
||||
for _, l := range p.levels {
|
||||
if l.position == pos {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return escalationLevel{}, false
|
||||
}
|
||||
|
||||
// loadEscalationPolicy reads one team's ladder. A team with no policy row
|
||||
// returns nil, which every caller treats as "not configured" rather than as an
|
||||
// error: most teams will never set one up.
|
||||
func loadEscalationPolicy(ctx context.Context, q querier, teamID int64) (*escalationPolicy, error) {
|
||||
p := &escalationPolicy{teamID: teamID}
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT repeat_count, fallback_topic FROM escalation_policies WHERE team_id = $1",
|
||||
teamID).Scan(&p.repeatCount, &p.fallbackTopic)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT l.id, l.position, l.timeout_seconds, t.kind, t.user_id
|
||||
FROM escalation_levels l
|
||||
LEFT JOIN escalation_targets t ON t.level_id = l.id
|
||||
WHERE l.team_id = $1
|
||||
ORDER BY l.position, t.id`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
byPosition := map[int64]int{} // position -> index in p.levels
|
||||
for rows.Next() {
|
||||
var id, position, timeout int64
|
||||
var kind *string
|
||||
var userID *int64
|
||||
if err := rows.Scan(&id, &position, &timeout, &kind, &userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx, seen := byPosition[position]
|
||||
if !seen {
|
||||
p.levels = append(p.levels, escalationLevel{
|
||||
id: id,
|
||||
position: position,
|
||||
timeout: time.Duration(timeout) * time.Second,
|
||||
})
|
||||
idx = len(p.levels) - 1
|
||||
byPosition[position] = idx
|
||||
}
|
||||
// LEFT JOIN: a level with no targets yet still produces a row, with a
|
||||
// NULL kind. It is a rung that pages nobody, which the API refuses to
|
||||
// store but an older row could still hold.
|
||||
if kind != nil {
|
||||
p.levels[idx].targets = append(p.levels[idx].targets,
|
||||
escalationTarget{kind: *kind, userID: userID})
|
||||
}
|
||||
}
|
||||
return p, rows.Err()
|
||||
}
|
||||
|
||||
// escalate advances every incident whose current level has run out of time.
|
||||
//
|
||||
// Runs on the notifier's tick, beside the reminder pass, because it is the same
|
||||
// question asked differently: reminders ask "has this been ignored long
|
||||
// enough to say it again", escalation asks "long enough to say it to somebody
|
||||
// else". Sharing the tick means one query cadence and one outbox.
|
||||
func escalate(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT i.id, i.team_id, i.escalation_level, i.escalation_level_at, i.escalation_round
|
||||
FROM incidents i
|
||||
JOIN escalation_policies p ON p.team_id = i.team_id
|
||||
WHERE i.resolved_at IS NULL
|
||||
AND i.archived_at IS NULL
|
||||
AND i.status = 'triggered'
|
||||
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $1)
|
||||
AND i.escalation_level > 0`, time.Now().Unix())
|
||||
if err != nil {
|
||||
log.Printf("escalation: find due: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type pending struct {
|
||||
incidentID, teamID, level, round int64
|
||||
levelAt int64
|
||||
}
|
||||
var due []pending
|
||||
for rows.Next() {
|
||||
var p pending
|
||||
var levelAt *int64
|
||||
if err := rows.Scan(&p.incidentID, &p.teamID, &p.level, &levelAt, &p.round); err != nil {
|
||||
rows.Close()
|
||||
log.Printf("escalation: scan: %v", err)
|
||||
return
|
||||
}
|
||||
if levelAt == nil {
|
||||
continue
|
||||
}
|
||||
p.levelAt = *levelAt
|
||||
due = append(due, p)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("escalation: iterate: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, d := range due {
|
||||
policy, err := loadEscalationPolicy(ctx, db, d.teamID)
|
||||
if err != nil {
|
||||
log.Printf("escalation: load policy for team %d: %v", d.teamID, err)
|
||||
continue
|
||||
}
|
||||
if !policy.configured() {
|
||||
continue
|
||||
}
|
||||
current, ok := policy.level(d.level)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if now.Sub(time.Unix(d.levelAt, 0)) < current.timeout {
|
||||
continue
|
||||
}
|
||||
if err := advanceEscalation(ctx, db, cfg, policy, d.incidentID, d.level, d.round, now); err != nil {
|
||||
log.Printf("escalation: advance incident %d: %v", d.incidentID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advanceEscalation moves one incident to its next rung, or off the end of the
|
||||
// ladder.
|
||||
//
|
||||
// The whole move is one transaction: the level, the page and the timeline entry
|
||||
// are one event, and an incident recorded as being at level 3 that nobody at
|
||||
// level 3 was told about is the worst of the possible half-states.
|
||||
func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy *escalationPolicy, incidentID, level, round int64, now time.Time) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
next := level + 1
|
||||
nextRound := round
|
||||
if _, ok := policy.level(next); !ok {
|
||||
// Off the end. Either start the chain again, or make the last call.
|
||||
if round < policy.repeatCount {
|
||||
next, nextRound = 1, round+1
|
||||
} else {
|
||||
if err := escalationExhausted(ctx, tx, policy, incidentID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
target, ok := policy.level(next)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
paged, err := pageLevel(ctx, tx, cfg, policy, incidentID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
SET escalation_level = $1, escalation_level_at = $2, escalation_round = $3
|
||||
WHERE id = $4`, next, now.Unix(), nextRound, incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detail := "level " + strconv.FormatInt(next, 10)
|
||||
if nextRound > round {
|
||||
detail += " (round " + strconv.FormatInt(nextRound+1, 10) + ")"
|
||||
}
|
||||
if len(paged) > 0 {
|
||||
detail += ": " + strings.Join(paged, ", ")
|
||||
} else {
|
||||
// Worth recording loudly: the rung exists, its turn came, and it woke
|
||||
// nobody. That is a policy that looks configured and is not.
|
||||
detail += ": nobody reachable"
|
||||
}
|
||||
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// escalationExhausted is the end of the line: the fallback topic, once, and a
|
||||
// timeline entry saying the ladder is finished. The incident stays triggered —
|
||||
// escalation running out is not the same as somebody answering.
|
||||
func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPolicy, incidentID int64, now time.Time) error {
|
||||
detail := "escalation exhausted"
|
||||
if policy.fallbackTopic != "" {
|
||||
if err := enqueueNotification(ctx, tx, incidentID, nil, policy.fallbackTopic, notifyEscalated); err != nil {
|
||||
return err
|
||||
}
|
||||
detail += ": paged " + policy.fallbackTopic
|
||||
} else {
|
||||
detail += ": no fallback topic configured"
|
||||
}
|
||||
|
||||
// Level 0 again, so the sweep stops considering it. The round counter is
|
||||
// left where it is, as the record of how far it got.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||
incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail)
|
||||
}
|
||||
|
||||
// pageLevel notifies every target of one level and reports who was woken.
|
||||
//
|
||||
// Each target gets its own outbox row, so each gets its own Acknowledge token:
|
||||
// the button in a notification must acknowledge as the person holding the
|
||||
// phone, not as whoever was paged first.
|
||||
func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, level escalationLevel) ([]string, error) {
|
||||
var paged []string
|
||||
seen := map[int64]bool{}
|
||||
|
||||
for _, t := range level.targets {
|
||||
userID := t.userID
|
||||
if t.kind == "oncall" {
|
||||
onCall, err := currentOnCall(ctx, tx, policy.teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if onCall == nil {
|
||||
continue
|
||||
}
|
||||
userID = onCall
|
||||
}
|
||||
if userID == nil || seen[*userID] {
|
||||
continue
|
||||
}
|
||||
seen[*userID] = true
|
||||
|
||||
var topic *string
|
||||
var username string
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT ntfy_topic, username FROM users WHERE id = $1 AND disabled_at IS NULL",
|
||||
*userID).Scan(&topic, &username); err != nil {
|
||||
// A disabled or deleted account is not an error in the middle of an
|
||||
// escalation: it is a target that cannot be woken, and the next
|
||||
// level is the answer to that.
|
||||
continue
|
||||
}
|
||||
if topic == nil || *topic == "" {
|
||||
continue
|
||||
}
|
||||
if err := enqueueNotification(ctx, tx, incidentID, userID, *topic, notifyEscalated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paged = append(paged, username)
|
||||
}
|
||||
return paged, nil
|
||||
}
|
||||
|
||||
// startEscalation puts a newly opened incident on the first rung, when its team
|
||||
// has a ladder. Called from openIncident, inside the same transaction, so an
|
||||
// incident is never briefly open with no escalation clock running.
|
||||
func startEscalation(ctx context.Context, q querier, incidentID, teamID int64) error {
|
||||
policy, err := loadEscalationPolicy(ctx, q, teamID)
|
||||
if err != nil || !policy.configured() {
|
||||
return err
|
||||
}
|
||||
_, err = q.ExecContext(ctx,
|
||||
"UPDATE incidents SET escalation_level = 1, escalation_level_at = $1 WHERE id = $2",
|
||||
time.Now().Unix(), incidentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// stopEscalation takes an incident off the ladder. Acknowledging or resolving
|
||||
// is somebody saying "I have this", and continuing to wake people after that is
|
||||
// the behaviour that teaches people to ignore the tool.
|
||||
func stopEscalation(ctx context.Context, q querier, incidentID int64) error {
|
||||
_, err := q.ExecContext(ctx,
|
||||
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||
incidentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleGetEscalation returns a team's ladder.
|
||||
func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||
}
|
||||
}
|
||||
|
||||
type escalationLevelJSON struct {
|
||||
Position int64 `json:"position"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Targets []escalationTargetJSON `json:"targets"`
|
||||
}
|
||||
|
||||
type escalationTargetJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type escalationJSON struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
RepeatCount int64 `json:"repeat_count"`
|
||||
FallbackTopic string `json:"fallback_topic"`
|
||||
Levels []escalationLevelJSON `json:"levels"`
|
||||
}
|
||||
|
||||
func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
|
||||
out := escalationJSON{TeamID: teamID, Levels: []escalationLevelJSON{}}
|
||||
if p == nil {
|
||||
return out
|
||||
}
|
||||
out.RepeatCount = p.repeatCount
|
||||
out.FallbackTopic = p.fallbackTopic
|
||||
for _, l := range p.levels {
|
||||
level := escalationLevelJSON{
|
||||
Position: l.position,
|
||||
TimeoutSeconds: int64(l.timeout.Seconds()),
|
||||
Targets: []escalationTargetJSON{},
|
||||
}
|
||||
for _, t := range l.targets {
|
||||
level.Targets = append(level.Targets, escalationTargetJSON{Kind: t.kind, UserID: t.userID})
|
||||
}
|
||||
out.Levels = append(out.Levels, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleSetEscalation replaces a team's ladder wholesale.
|
||||
//
|
||||
// Replace rather than patch: the levels are an order, and an API that edits one
|
||||
// rung has to answer what happens to the numbering of the others. Sending the
|
||||
// whole ladder makes the order the client's to decide and the server's to
|
||||
// store, and makes an edit atomic — there is no moment where level 2 exists
|
||||
// twice.
|
||||
func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req escalationJSON
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
if req.RepeatCount < 0 || req.RepeatCount > 10 {
|
||||
respond(w, http.StatusBadRequest, errResp("repeat_count must be between 0 and 10"))
|
||||
return
|
||||
}
|
||||
for i, l := range req.Levels {
|
||||
if l.TimeoutSeconds <= 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("every level needs a timeout"))
|
||||
return
|
||||
}
|
||||
if len(l.Targets) == 0 {
|
||||
// A rung that pages nobody is not a delay, it is a silence with
|
||||
// a number on it.
|
||||
respond(w, http.StatusBadRequest,
|
||||
errResp("level "+strconv.FormatInt(int64(i+1), 10)+" has no targets"))
|
||||
return
|
||||
}
|
||||
for _, t := range l.Targets {
|
||||
switch t.Kind {
|
||||
case "oncall":
|
||||
if t.UserID != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("an oncall target takes no user_id"))
|
||||
return
|
||||
}
|
||||
case "user":
|
||||
if t.UserID == nil {
|
||||
respond(w, http.StatusBadRequest, errResp("a user target needs a user_id"))
|
||||
return
|
||||
}
|
||||
default:
|
||||
respond(w, http.StatusBadRequest, errResp("target kind must be user or oncall"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO escalation_policies (team_id, repeat_count, fallback_topic, updated_at)
|
||||
VALUES ($1, $2, $3, `+nowEpoch+`)
|
||||
ON CONFLICT (team_id) DO UPDATE SET
|
||||
repeat_count = excluded.repeat_count,
|
||||
fallback_topic = excluded.fallback_topic,
|
||||
updated_at = excluded.updated_at`,
|
||||
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
// The levels are replaced, not merged; the cascade takes the targets.
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"DELETE FROM escalation_levels WHERE team_id = $1", teamID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
for i, l := range req.Levels {
|
||||
var levelID int64
|
||||
if err := tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO escalation_levels (team_id, position, timeout_seconds)
|
||||
VALUES ($1, $2, $3) RETURNING id`,
|
||||
teamID, int64(i+1), l.TimeoutSeconds).Scan(&levelID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
for _, t := range l.Targets {
|
||||
if _, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO escalation_targets (level_id, kind, user_id)
|
||||
VALUES ($1, $2, $3)`, levelID, t.Kind, t.UserID); err != nil {
|
||||
// The only foreign key here is the user.
|
||||
respond(w, http.StatusBadRequest, errResp("unknown user in targets"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// teamUser creates a user in the default team with an ntfy topic, so they can
|
||||
// actually be paged.
|
||||
func teamUser(t *testing.T, s *ts, username, topic string) int64 {
|
||||
t.Helper()
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": username, "email": username + "@test.com"}), &user)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
setTopic(t, s, int(user.ID), topic)
|
||||
return user.ID
|
||||
}
|
||||
|
||||
// Escalation is all timeouts, and there is no fake clock in this package. The
|
||||
// tests back-date escalation_level_at instead, which is the same trick the dead
|
||||
// man's switch tests use on received_at: the sweeper reads a stored timestamp,
|
||||
// so moving the timestamp is moving the clock.
|
||||
|
||||
// ladder configures the default team with two levels: the rota first, then a
|
||||
// named person, then the fallback topic.
|
||||
func ladder(t *testing.T, s *ts, secondUserID int64, repeat int64, fallback string) {
|
||||
t.Helper()
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||
"repeat_count": repeat,
|
||||
"fallback_topic": fallback,
|
||||
"levels": []map[string]any{
|
||||
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}},
|
||||
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user", "user_id": secondUserID}}},
|
||||
},
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("configure the ladder: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// overdue back-dates an incident's current level so its timeout has passed.
|
||||
func overdue(t *testing.T, s *ts, incidentID int64) {
|
||||
t.Helper()
|
||||
s.exec(t, "UPDATE incidents SET escalation_level_at = $1 WHERE id = $2",
|
||||
time.Now().Add(-time.Hour).Unix(), incidentID)
|
||||
}
|
||||
|
||||
func escalationLevel(t *testing.T, s *ts, incidentID int64) (level, round int64) {
|
||||
t.Helper()
|
||||
if err := s.db.QueryRow(
|
||||
"SELECT escalation_level, escalation_round FROM incidents WHERE id = $1",
|
||||
incidentID).Scan(&level, &round); err != nil {
|
||||
t.Fatalf("read escalation state: %v", err)
|
||||
}
|
||||
return level, round
|
||||
}
|
||||
|
||||
// The whole point: nobody answers, so somebody else is woken.
|
||||
func TestEscalation_PagesTheNextLevel(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-esc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
// Level 1 is the rota, so the first page went to the admin.
|
||||
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||
t.Fatalf("a new incident should start at level 1, got %d", level)
|
||||
}
|
||||
if got := f.topicsSince(t); len(got) == 0 || got[0] != "terdut-admin" {
|
||||
t.Fatalf("the first page should go to the on-call user, went to %v", got)
|
||||
}
|
||||
|
||||
// Time passes with no acknowledgement.
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
|
||||
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||
t.Errorf("expected level 2, got %d", level)
|
||||
}
|
||||
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-second" {
|
||||
t.Errorf("level 2 should page the named user, paged %v", got)
|
||||
}
|
||||
|
||||
// And the timeline says so, which is what somebody reads afterwards to
|
||||
// understand why their phone rang at 04:00.
|
||||
timeline := list(t, s.req(t, http.MethodGet, "/api/incidents/1/timeline", nil))
|
||||
found := ""
|
||||
for _, e := range timeline {
|
||||
if e["type"] == "escalated" {
|
||||
found, _ = e["detail"].(string)
|
||||
}
|
||||
}
|
||||
if found == "" {
|
||||
t.Error("the timeline should record the escalation")
|
||||
} else if !strings.HasPrefix(found, "level 2") || !strings.Contains(found, "second") {
|
||||
t.Errorf("the escalation entry should say which level and who: %q", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledging is somebody saying "I have this". Nobody else should be woken.
|
||||
func TestEscalation_AcknowledgementStopsIt(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-ack", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||
t.Errorf("acknowledging should take the incident off the ladder, level is %d", level)
|
||||
}
|
||||
|
||||
f.forget()
|
||||
overdue(t, s, 1) // no-op: level is 0, so there is nothing due
|
||||
s.sweepNotify(t)
|
||||
if got := f.topicsSince(t); len(got) != 0 {
|
||||
t.Errorf("an acknowledged incident should page nobody, paged %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolving stops it too, and by the same mechanism.
|
||||
func TestEscalation_ResolutionStopsIt(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-res", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||
t.Errorf("a resolved incident should be off the ladder, level is %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
// Snoozing is a deliberate "not now", so the ladder waits rather than carrying
|
||||
// on without the person who asked for quiet.
|
||||
func TestEscalation_SnoozePausesIt(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-snooze", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze", map[string]any{"duration": "1h"})
|
||||
resp.Body.Close()
|
||||
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
|
||||
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||
t.Errorf("a snoozed incident should stay where it is, level is %d", level)
|
||||
}
|
||||
if got := f.topicsSince(t); len(got) != 0 {
|
||||
t.Errorf("a snoozed incident should page nobody, paged %v", got)
|
||||
}
|
||||
|
||||
// When the snooze ends, the ladder picks up where it left off.
|
||||
s.exec(t, "UPDATE incidents SET snoozed_until = $1 WHERE id = 1", time.Now().Add(-time.Minute).Unix())
|
||||
s.sweepNotify(t)
|
||||
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||
t.Errorf("after the snooze the ladder should resume, level is %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
// Running out of ladder pages the team's fallback topic once, and says so.
|
||||
func TestEscalation_ExhaustionPagesTheFallback(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-end", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t) // level 2
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t) // off the end
|
||||
|
||||
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-fallback" {
|
||||
t.Errorf("exhaustion should page the fallback topic once, paged %v", got)
|
||||
}
|
||||
level, _ := escalationLevel(t, s, 1)
|
||||
if level != 0 {
|
||||
t.Errorf("an exhausted ladder should stop asking, level is %d", level)
|
||||
}
|
||||
|
||||
// The incident is still open: running out of people is not an answer.
|
||||
var status string
|
||||
if err := s.db.QueryRow("SELECT status FROM incidents WHERE id = 1").Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "triggered" {
|
||||
t.Errorf("exhaustion must not resolve the incident, status is %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// repeat_count walks the whole ladder again before giving up.
|
||||
func TestEscalation_RepeatsTheChain(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 1, "terdut-fallback") // one extra round
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-repeat", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t) // level 2
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t) // back to level 1, round 2
|
||||
|
||||
level, round := escalationLevel(t, s, 1)
|
||||
if level != 1 || round != 1 {
|
||||
t.Errorf("expected level 1 round 1, got level %d round %d", level, round)
|
||||
}
|
||||
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||
t.Errorf("the second round should start at the top again, paged %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A team without a ladder keeps exactly the behaviour it had, and never gets
|
||||
// both a reminder and an escalation for the same silence.
|
||||
func TestEscalation_WithoutAPolicyRemindersStillRun(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-noesc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
// Age the first notification past the repeat interval.
|
||||
f.forget()
|
||||
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||
time.Now().Add(-time.Hour).Unix())
|
||||
s.sweepNotify(t)
|
||||
|
||||
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||
t.Errorf("without a ladder the reminder should still fire, paged %v", got)
|
||||
}
|
||||
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||
t.Errorf("an incident in a team with no ladder should not be on one, level is %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
// With a ladder, reminders stop: two pages for one silence is how people learn
|
||||
// to mute the tool.
|
||||
func TestEscalation_WithAPolicyRemindersDoNotAlsoFire(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-both", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
f.forget()
|
||||
// Old enough for a reminder, but not yet due for escalation.
|
||||
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||
time.Now().Add(-time.Hour).Unix())
|
||||
s.sweepNotify(t)
|
||||
|
||||
if got := f.topicsSince(t); len(got) != 0 {
|
||||
t.Errorf("a team with a ladder should not also get reminders, paged %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The API refuses a ladder that cannot page anybody.
|
||||
func TestEscalation_RejectsAnUnusablePolicy(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"a level with no targets", map[string]any{
|
||||
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{}}},
|
||||
}},
|
||||
{"a level with no timeout", map[string]any{
|
||||
"levels": []map[string]any{{"timeout_seconds": 0, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||
}},
|
||||
{"a user target with no user", map[string]any{
|
||||
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user"}}}},
|
||||
}},
|
||||
{"an unknown target kind", map[string]any{
|
||||
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "everybody"}}}},
|
||||
}},
|
||||
{"an absurd repeat count", map[string]any{
|
||||
"repeat_count": 99,
|
||||
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||
}},
|
||||
} {
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Editing the ladder is an owner's job; reading it is any member's.
|
||||
func TestEscalation_OwnerOnlyToEdit(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, call := member(t, s, "plain")
|
||||
|
||||
resp := call(http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a member editing the ladder: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = call(http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a member reading the ladder: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A target who cannot be woken is not a reason to stop: the next level is the
|
||||
// answer to an unreachable one.
|
||||
func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
// Second user has no ntfy topic at all.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "silent", "email": "silent@test.com"}), &user)
|
||||
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
|
||||
|
||||
ladder(t, s, user.ID, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-silent", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
f.forget()
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
|
||||
// Level 2 was entered even though it woke nobody, so the ladder keeps
|
||||
// moving toward the fallback rather than stalling on a silent rung.
|
||||
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||
t.Errorf("expected the ladder to advance past an unreachable target, level is %d", level)
|
||||
}
|
||||
if got := f.topicsSince(t); len(got) != 0 {
|
||||
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -51,12 +51,13 @@ type querier interface {
|
||||
}
|
||||
|
||||
const incidentSelectFrom = `
|
||||
SELECT i.id, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
||||
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
||||
i.triggered_at,
|
||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||
i.assigned_to, asg.username, i.snoozed_until,
|
||||
i.resolved_at, i.resolution_source, i.archived_at
|
||||
FROM incidents i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
||||
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
||||
|
||||
@@ -67,7 +68,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
||||
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
|
||||
|
||||
if err := s.Scan(
|
||||
&i.ID, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
||||
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
||||
&triggeredAt,
|
||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||
@@ -95,7 +96,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 +104,7 @@ func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, e
|
||||
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
incidentID, evType, userID, alertID, detail, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
@@ -113,12 +114,17 @@ func todayUTC() string {
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// currentOnCall returns today's on-call user, or nil when nobody is scheduled.
|
||||
// A missing schedule entry is not an error — incidents just open unassigned.
|
||||
func currentOnCall(ctx context.Context, q querier) (*int64, error) {
|
||||
// currentOnCall returns a team's on-call user for today, or nil when nobody is
|
||||
// scheduled. A missing schedule entry is not an error — incidents just open
|
||||
// unassigned.
|
||||
//
|
||||
// Per team: each team keeps its own rota, so two teams can have two different
|
||||
// people on call on the same day, which was the point of scoping the schedule.
|
||||
func currentOnCall(ctx context.Context, q querier, teamID int64) (*int64, error) {
|
||||
var userID int64
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT user_id FROM schedule_entries WHERE date = ?", todayUTC()).Scan(&userID)
|
||||
"SELECT user_id FROM schedule_entries WHERE team_id = $1 AND date = $2",
|
||||
teamID, todayUTC()).Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -154,10 +160,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 +191,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 +216,9 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
res, err := q.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
SET status = 'resolved',
|
||||
resolved_at = ?,
|
||||
resolution_source = ?
|
||||
WHERE id = ?
|
||||
resolved_at = $1,
|
||||
resolution_source = $2
|
||||
WHERE id = $3
|
||||
AND resolved_at IS NULL
|
||||
-- An incident with no members yet is mid-creation, not settled.
|
||||
AND EXISTS (SELECT 1 FROM incident_alerts ia WHERE ia.incident_id = incidents.id)
|
||||
@@ -229,6 +235,9 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
if n == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -245,8 +254,8 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
||||
res, err := q.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
SET status = 'acknowledged', acknowledged_by = ?, acknowledged_at = ?
|
||||
WHERE id = ? AND resolved_at IS NULL`,
|
||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
||||
WHERE id = $3 AND resolved_at IS NULL`,
|
||||
userID, time.Now().Unix(), incidentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -254,6 +263,10 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return false, nil
|
||||
}
|
||||
// Somebody has it: stop waking anybody else.
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
||||
}
|
||||
|
||||
@@ -266,7 +279,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
|
||||
}
|
||||
|
||||
+70
-50
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
@@ -17,13 +17,21 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
q := r.URL.Query()
|
||||
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
args := &sqlArgs{}
|
||||
|
||||
// The combined queue: every team the caller belongs to, in one list. A
|
||||
// caller in no team sees an empty queue rather than everybody's.
|
||||
where = append(where, "i.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
|
||||
if team := q.Get("team_id"); team != "" {
|
||||
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
|
||||
where = append(where, "i.team_id = "+args.add(n))
|
||||
}
|
||||
}
|
||||
|
||||
// Without an explicit status the queue shows open work, which is what an
|
||||
// on-call person opens the tool to see.
|
||||
if status := q.Get("status"); status != "" {
|
||||
where = append(where, "i.status = ?")
|
||||
args = append(args, status)
|
||||
where = append(where, "i.status = "+args.add(status))
|
||||
} else {
|
||||
where = append(where, "i.resolved_at IS NULL")
|
||||
}
|
||||
@@ -36,33 +44,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 +79,11 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
if q.Get("sort") == "severity" {
|
||||
order = severityRankSQL("i.severity") + " DESC, i.triggered_at DESC"
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
fmt.Sprintf("%s WHERE %s ORDER BY %s LIMIT ?",
|
||||
incidentSelectFrom, strings.Join(where, " AND "), order),
|
||||
args...)
|
||||
fmt.Sprintf("%s WHERE %s ORDER BY %s LIMIT %s",
|
||||
incidentSelectFrom, strings.Join(where, " AND "), order, args.add(limit)),
|
||||
args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -104,7 +105,7 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -127,7 +128,7 @@ func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
e.alert_id, e.detail, e.created_at
|
||||
FROM incident_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
WHERE e.incident_id = ?
|
||||
WHERE e.incident_id = $1
|
||||
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -184,7 +185,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -207,14 +208,14 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
|
||||
WHERE id = ? AND resolved_at IS NULL`, id) {
|
||||
WHERE id = $1 AND resolved_at IS NULL`, id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
|
||||
@@ -231,17 +232,22 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||
// re-send of an alert that never stopped firing. Use snooze for "not now".
|
||||
func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = ?, resolution_source = ?
|
||||
WHERE id = ? AND resolved_at IS NULL`,
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||
WHERE id = $3 AND resolved_at IS NULL`,
|
||||
time.Now().Unix(), incidentResolutionManual, id) {
|
||||
return
|
||||
}
|
||||
// A person closing an incident is the clearest possible "I have this".
|
||||
if err := stopEscalation(r.Context(), db, id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -252,7 +258,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -269,13 +275,13 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
|
||||
"SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET assigned_to = ? WHERE id = ? AND resolved_at IS NULL",
|
||||
"UPDATE incidents SET assigned_to = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||
req.UserID, id) {
|
||||
return
|
||||
}
|
||||
@@ -293,7 +299,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
// {"duration": "2h"}.
|
||||
func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -333,7 +339,7 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = ? WHERE id = ? AND resolved_at IS NULL",
|
||||
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||
until.Unix(), id) {
|
||||
return
|
||||
}
|
||||
@@ -348,13 +354,13 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = NULL WHERE id = ? AND resolved_at IS NULL", id) {
|
||||
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
|
||||
@@ -367,12 +373,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE incidents SET archived_at = unixepoch() WHERE id = ?", id)
|
||||
"UPDATE incidents SET archived_at = "+nowEpoch+" WHERE id = $1", id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -387,12 +393,12 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE incidents SET archived_at = NULL WHERE id = ?", id)
|
||||
"UPDATE incidents SET archived_at = NULL WHERE id = $1", id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -409,7 +415,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
// single query renders the whole story of an incident in order.
|
||||
func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -430,14 +436,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,
|
||||
@@ -455,7 +462,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
// rest of the timeline is what actually happened, and is not editable.
|
||||
func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r)
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -468,7 +475,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"))
|
||||
@@ -486,19 +493,32 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
// Shared handler plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func incidentIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
// incidentIDParam reads {id} from the path AND confirms the incident belongs to
|
||||
// a team the caller is in. Both in one place, deliberately: every incident route
|
||||
// goes through here, so scoping cannot be forgotten by writing a new handler
|
||||
// that only remembers the first half.
|
||||
//
|
||||
// An incident in somebody else's team is reported as not found rather than
|
||||
// forbidden, because "there is an incident 41 you may not see" is itself
|
||||
// something only that team should know.
|
||||
func incidentIDParam(w http.ResponseWriter, r *http.Request, db *sql.DB) (int64, bool) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid incident id"))
|
||||
return 0, false
|
||||
}
|
||||
if !incidentExists(w, r, db, id) {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// incidentExists reports whether the incident is one the caller may see at all.
|
||||
func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) bool {
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT 1 FROM incidents WHERE id = ?", id).Scan(&exists); err != nil {
|
||||
"SELECT 1 FROM incidents WHERE id = $1 AND team_id = ANY($2)",
|
||||
id, callerTeamIDs(r.Context())).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||
return false
|
||||
}
|
||||
@@ -538,7 +558,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
|
||||
|
||||
+60
-103
@@ -4,14 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
// amAlert builds one alert of a webhook payload.
|
||||
@@ -288,7 +284,7 @@ func TestExpiry_CascadesToIncidentResolution(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postAlert(t, s, "fp-exp", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
|
||||
|
||||
s.exec(t, "UPDATE alerts SET received_at = ? WHERE fingerprint = 'fp-exp'",
|
||||
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-exp'",
|
||||
time.Now().Add(-10*time.Hour).Unix())
|
||||
sweep(t, s, 6*time.Hour)
|
||||
|
||||
@@ -429,7 +425,7 @@ func TestIncident_AutoAssignedToCurrentOnCall(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{today}})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
|
||||
@@ -611,9 +607,9 @@ func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
|
||||
s.exec(t, "UPDATE incidents SET resolved_at = ? WHERE id = 1",
|
||||
s.exec(t, "UPDATE incidents SET resolved_at = $1 WHERE id = 1",
|
||||
time.Now().Add(-30*24*time.Hour).Unix())
|
||||
api.Sweep(context.Background(), s.db, 7*24*time.Hour, 6*time.Hour, s.deadman, s.notify)
|
||||
api.Sweep(context.Background(), s.db, 7*24*time.Hour, 6*time.Hour, s.notify)
|
||||
|
||||
if inc := getIncident(t, s, 1); inc["archived_at"] == nil {
|
||||
t.Error("expected the sweeper to archive a long-resolved incident")
|
||||
@@ -653,6 +649,62 @@ func TestStats_Incidents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An empty window is a report of zero, not a failure. SUM over no rows is NULL
|
||||
// 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)
|
||||
|
||||
// No incidents at all.
|
||||
resp := s.req(t, http.MethodGet, "/api/stats/incidents", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
|
||||
}
|
||||
var stats map[string]any
|
||||
decode(t, resp, &stats)
|
||||
for _, k := range []string{"total", "triggered", "acknowledged", "resolved"} {
|
||||
if stats[k].(float64) != 0 {
|
||||
t.Errorf("expected %s 0, got %v", k, stats[k])
|
||||
}
|
||||
}
|
||||
|
||||
// And with every incident archived out of the window.
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-s4", "Gone", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
|
||||
|
||||
resp = s.req(t, http.MethodGet, "/api/stats/incidents", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("expected 200 when every incident is archived, got %d", resp.StatusCode)
|
||||
}
|
||||
stats = nil
|
||||
decode(t, resp, &stats)
|
||||
if stats["total"].(float64) != 0 {
|
||||
t.Errorf("expected total 0, got %v", stats["total"])
|
||||
}
|
||||
}
|
||||
|
||||
// The alert stats share the same aggregate, and the same empty-window trap.
|
||||
func TestStats_AlertsEmptyWindowIsZeroNotAnError(t *testing.T) {
|
||||
s := newTS(t)
|
||||
resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
|
||||
}
|
||||
var stats map[string]any
|
||||
decode(t, resp, &stats)
|
||||
for _, k := range []string{"total", "firing", "resolved"} {
|
||||
if stats[k].(float64) != 0 {
|
||||
t.Errorf("expected %s 0, got %v", k, stats[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing acknowledged yet means "no data", which is not the same claim as zero.
|
||||
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
|
||||
s := newTS(t)
|
||||
@@ -667,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(¬es); 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 {
|
||||
|
||||
+229
-17
@@ -14,50 +14,262 @@ import (
|
||||
|
||||
type contextKey string
|
||||
|
||||
const ctxUser contextKey = "user"
|
||||
const (
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
)
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
||||
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
||||
//
|
||||
// 403 and not 404: the route exists and the caller is authenticated, they are
|
||||
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
|
||||
// is in the README.
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok || !caller.IsAdmin {
|
||||
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requireSelfOrAdmin guards the endpoints that are self-service for your own
|
||||
// account and administration for anybody else's: your password, your ntfy
|
||||
// topic, your API keys. Reports whether the request may proceed, and answers it
|
||||
// if not.
|
||||
//
|
||||
// An API key is not an escalation: it carries exactly the rights of the user it
|
||||
// belongs to, so minting your own is no more than signing in again.
|
||||
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
|
||||
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// apiKeyUser resolves an API key to its user and stamps its last use.
|
||||
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||
var keyID, userID int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id FROM api_keys WHERE key_hash = $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
|
||||
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
|
||||
// a disabled account is one that cannot authenticate, by either credential,
|
||||
// and the way to be sure of that is for there to be no path where the row
|
||||
// is loaded and the flag is then forgotten.
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT id, username, email, created_at FROM users WHERE id = ?", userID,
|
||||
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
|
||||
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
|
||||
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
|
||||
})
|
||||
// Every scoped query needs the caller's teams, so they are loaded once here
|
||||
// rather than per handler. One extra round trip per request, against a
|
||||
// table with one row per membership.
|
||||
teams, err := callerMemberships(r.Context(), db, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
||||
ctx = context.WithValue(ctx, ctxUser, u)
|
||||
if sessionID != 0 {
|
||||
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// membership is the caller's role in one team.
|
||||
type membership struct {
|
||||
teamID int64
|
||||
role string
|
||||
}
|
||||
|
||||
func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []membership
|
||||
for rows.Next() {
|
||||
var m membership
|
||||
if err := rows.Scan(&m.teamID, &m.role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY`
|
||||
// filter every list query carries. An admin is NOT implicitly in every team:
|
||||
// administration is about accounts, not about reading other people's incidents,
|
||||
// and an admin who needs to see a team's queue can add themselves to it.
|
||||
func callerTeamIDs(ctx context.Context) []int64 {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
ids := make([]int64, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
ids = append(ids, m.teamID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// callerRole reports the caller's role in one team, and whether they are in it
|
||||
// at all.
|
||||
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
for _, m := range ms {
|
||||
if m.teamID == teamID {
|
||||
return m.role, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// requireTeamMember answers the request and reports false unless the caller
|
||||
// belongs to teamID.
|
||||
//
|
||||
// 404, not 403: whether a team exists is itself something only its members
|
||||
// should learn, and the same reasoning applies to every incident and alert
|
||||
// under it.
|
||||
func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
||||
if _, ok := callerRole(r.Context(), teamID); !ok {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireTeamOwner is requireTeamMember for the things only an owner may change:
|
||||
// the schedule, the integrations and who is in the team. A system administrator
|
||||
// passes without being a member, because somebody has to be able to repair a
|
||||
// team whose owner has left.
|
||||
func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
||||
role, ok := callerRole(r.Context(), teamID)
|
||||
if ok && role == models.RoleOwner {
|
||||
return true
|
||||
}
|
||||
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
|
||||
return true
|
||||
}
|
||||
if !ok {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return false
|
||||
}
|
||||
respond(w, http.StatusForbidden, errResp("team owner access required"))
|
||||
return false
|
||||
}
|
||||
|
||||
// sessionFromContext returns the id of the session a request was authenticated
|
||||
// with, or false for an API-key request.
|
||||
func sessionFromContext(ctx context.Context) (int64, bool) {
|
||||
id, ok := ctx.Value(ctxSession).(int64)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
+35
-16
@@ -43,6 +43,10 @@ const (
|
||||
notifyTriggered = "triggered"
|
||||
notifyReminder = "reminder"
|
||||
notifyResolved = "resolved"
|
||||
|
||||
// notifyEscalated is a page that went out because nobody answered the last
|
||||
// one. Told apart from a reminder because it goes to somebody else.
|
||||
notifyEscalated = "escalated"
|
||||
)
|
||||
|
||||
// Timeline event types the notifier writes, so an incident's history says who
|
||||
@@ -120,6 +124,9 @@ func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
// Exported so tests can drive a pass without waiting on the ticker.
|
||||
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
enqueueReminders(ctx, db, cfg)
|
||||
// Escalation before delivery, so a level that comes due on this tick is
|
||||
// paged on this tick rather than waiting for the next one.
|
||||
escalate(ctx, db, cfg)
|
||||
deliverPending(ctx, db, cfg)
|
||||
}
|
||||
|
||||
@@ -134,7 +141,11 @@ func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
// queued, so an ntfy outage produces a retry backlog rather than a reminder
|
||||
// backlog that all lands at once when it comes back.
|
||||
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
if cfg.RepeatEvery <= 0 {
|
||||
// cfg.RepeatEvery is what the server started with; the settings table is
|
||||
// what it runs on. Read per tick, so an administrator lengthening the
|
||||
// interval at 02:00 is obeyed at 02:00 and not at the next restart.
|
||||
repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
|
||||
if repeat <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
@@ -151,19 +162,25 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
JOIN incidents i ON i.id = n.incident_id
|
||||
WHERE n.id = (SELECT MAX(id) FROM notifications WHERE incident_id = n.incident_id)
|
||||
AND n.sent_at IS NOT NULL
|
||||
AND n.created_at <= ?
|
||||
AND n.created_at <= $1
|
||||
AND i.resolved_at IS NULL
|
||||
AND i.archived_at IS NULL
|
||||
AND i.status = 'triggered'
|
||||
AND (i.snoozed_until IS NULL OR i.snoozed_until <= ?)`,
|
||||
now.Add(-cfg.RepeatEvery).Unix(), now.Unix())
|
||||
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
|
||||
-- A team with an escalation ladder gets escalation instead. Both
|
||||
-- would mean two pages for one silence, which is how people learn to
|
||||
-- mute a tool.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)`,
|
||||
now.Add(-repeat).Unix(), now.Unix())
|
||||
if err != nil {
|
||||
log.Printf("notifier: find reminders: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Collected before inserting: the pool is limited to a single connection, so
|
||||
// an open cursor would block the writes behind it.
|
||||
// Collected before inserting, rather than written while walking the cursor:
|
||||
// the inserts below are what this query selects on, and a cursor reading its
|
||||
// own writes is a hazard whatever the pool size.
|
||||
var pending []due
|
||||
for rows.Next() {
|
||||
var d due
|
||||
@@ -217,7 +234,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"UPDATE notifications SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?",
|
||||
"UPDATE notifications SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE id = $2",
|
||||
time.Now().Unix(), n.id); err != nil {
|
||||
log.Printf("notifier: mark sent %d: %v", n.id, err)
|
||||
}
|
||||
@@ -240,10 +257,10 @@ func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error)
|
||||
SELECT id, incident_id, user_id, topic, kind, attempts
|
||||
FROM notifications
|
||||
WHERE sent_at IS NULL
|
||||
AND send_after <= ?
|
||||
AND attempts < ?
|
||||
AND send_after <= $1
|
||||
AND attempts < $2
|
||||
ORDER BY id
|
||||
LIMIT ?`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
|
||||
LIMIT $3`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -269,7 +286,7 @@ func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error)
|
||||
func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
|
||||
next := time.Now().Add(retryDelay(n.attempts)).Unix()
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"UPDATE notifications SET attempts = attempts + 1, send_after = ?, last_error = ? WHERE id = ?",
|
||||
"UPDATE notifications SET attempts = attempts + 1, send_after = $1, last_error = $2 WHERE id = $3",
|
||||
next, cause.Error(), n.id); err != nil {
|
||||
log.Printf("notifier: mark failed %d: %v", n.id, err)
|
||||
}
|
||||
@@ -307,7 +324,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 +376,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 +492,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 +506,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 +539,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-10
@@ -69,6 +69,27 @@ func (f *fakeNtfy) messages() []pushed {
|
||||
return append([]pushed(nil), f.got...)
|
||||
}
|
||||
|
||||
// topicsSince lists the topics published to since the last forget, which is how
|
||||
// the escalation tests ask "who did this tick wake".
|
||||
func (f *fakeNtfy) topicsSince(t *testing.T) []string {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]string, 0, len(f.got))
|
||||
for _, m := range f.got {
|
||||
out = append(out, m.Topic)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// forget drops what has been published so far, so the next assertion is about
|
||||
// this tick rather than the whole test.
|
||||
func (f *fakeNtfy) forget() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.got = nil
|
||||
}
|
||||
|
||||
func (f *fakeNtfy) failWith(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -95,7 +116,7 @@ func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
|
||||
func putOnCall(t *testing.T, s *ts, userID int) {
|
||||
t.Helper()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": userID, "dates": []string{today}})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
@@ -125,7 +146,7 @@ func (s *ts) countNotifications(t *testing.T, kind string) int {
|
||||
query := "SELECT COUNT(*) FROM notifications"
|
||||
args := []any{}
|
||||
if kind != "" {
|
||||
query += " WHERE kind = ?"
|
||||
query += " WHERE kind = $1"
|
||||
args = append(args, kind)
|
||||
}
|
||||
if err := s.db.QueryRow(query, args...).Scan(&n); err != nil {
|
||||
@@ -175,7 +196,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 +347,7 @@ func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
|
||||
// One pass per attempt, each made due by clearing the backoff the last one set.
|
||||
for i := 0; i < 10; i++ {
|
||||
s.sweepNotify(t)
|
||||
s.exec(t, "UPDATE notifications SET send_after = ? WHERE sent_at IS NULL",
|
||||
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE sent_at IS NULL",
|
||||
time.Now().Add(-time.Second).Unix())
|
||||
}
|
||||
|
||||
@@ -415,7 +436,7 @@ func TestNotify_AckRejectsExpiredToken(t *testing.T) {
|
||||
|
||||
// Age the token past its TTL. The token's inputs are wall-clock timestamps,
|
||||
// so this is the same trick the sweeper tests use.
|
||||
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = ?", time.Now().Add(-time.Minute).Unix())
|
||||
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
resp, err := http.Post(s.URL+path, "application/json", nil)
|
||||
if err != nil {
|
||||
@@ -436,9 +457,9 @@ func TestNotify_SweepPurgesExpiredAckTokens(t *testing.T) {
|
||||
|
||||
fireCritical(t, s)
|
||||
s.sweepNotify(t)
|
||||
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = ?", time.Now().Add(-time.Minute).Unix())
|
||||
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
api.Sweep(context.Background(), s.db, 168*time.Hour, 6*time.Hour, s.deadman, s.notify)
|
||||
api.Sweep(context.Background(), s.db, 168*time.Hour, 6*time.Hour, s.notify)
|
||||
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM incident_ack_tokens").Scan(&n); err != nil {
|
||||
@@ -457,7 +478,7 @@ func TestNotify_SweepPurgesExpiredAckTokens(t *testing.T) {
|
||||
// reminder as due.
|
||||
func (s *ts) ageNotifications(t *testing.T, by time.Duration) {
|
||||
t.Helper()
|
||||
s.exec(t, "UPDATE notifications SET created_at = ? WHERE sent_at IS NOT NULL",
|
||||
s.exec(t, "UPDATE notifications SET created_at = $1 WHERE sent_at IS NOT NULL",
|
||||
time.Now().Add(-by).Unix())
|
||||
}
|
||||
|
||||
@@ -693,7 +714,7 @@ func TestNotify_FailedDeliveryRetriesWithBackoff(t *testing.T) {
|
||||
|
||||
// Once due and once ntfy recovers, it goes out.
|
||||
f.failWith(http.StatusOK)
|
||||
s.exec(t, "UPDATE notifications SET send_after = ? WHERE id = 1", time.Now().Add(-time.Second).Unix())
|
||||
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE id = 1", time.Now().Add(-time.Second).Unix())
|
||||
s.sweepNotify(t)
|
||||
|
||||
if err := s.db.QueryRow("SELECT sent_at FROM notifications WHERE id = 1").Scan(&sentAt); err != nil {
|
||||
@@ -715,7 +736,7 @@ func TestNotify_UnsentNotificationBlocksReminders(t *testing.T) {
|
||||
|
||||
fireCritical(t, s)
|
||||
s.sweepNotify(t)
|
||||
s.exec(t, "UPDATE notifications SET created_at = ?", time.Now().Add(-time.Hour).Unix())
|
||||
s.exec(t, "UPDATE notifications SET created_at = $1", time.Now().Add(-time.Hour).Unix())
|
||||
s.sweepNotify(t)
|
||||
|
||||
if got := s.countNotifications(t, "reminder"); got != 0 {
|
||||
|
||||
+91
-12
@@ -4,15 +4,17 @@ import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// NewRouter builds the HTTP surface. notify and deadman are passed through to
|
||||
// the webhook, the only handler that has to decide where a new incident's page
|
||||
// goes and which arriving alerts are heartbeats rather than problems. A zero
|
||||
// notify disables notifications; a zero deadman disables dead man's switches.
|
||||
func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Handler {
|
||||
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
|
||||
// the only handler that has to decide where a new incident's page goes; a zero
|
||||
// notify disables notifications. Dead man's switches are per team and read from
|
||||
// the database, so nothing about them is wired in here.
|
||||
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -26,20 +28,58 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
|
||||
// the scoped token in its path rather than an API key, and has to stay
|
||||
// reachable from outside the cluster for the button to work.
|
||||
r.Post("/api/bootstrap", handleBootstrap(db))
|
||||
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman))
|
||||
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
||||
|
||||
// Alert ingestion. The key in the path says both that the sender may post
|
||||
// and which team the alerts belong to, which is why it needs no session.
|
||||
//
|
||||
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
|
||||
// took no credential at all, was removed in v0.13.0 once the cluster's
|
||||
// Alertmanager had moved onto a key; a sender still posting there gets the
|
||||
// JSON 404 every unknown /api path gets.
|
||||
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
||||
|
||||
// Signing 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))
|
||||
|
||||
// Readable by anyone signed in: the queue's assignment control and the
|
||||
// on-call schedule both need to name people.
|
||||
r.Get("/api/users", handleListUsers(db))
|
||||
r.Post("/api/users", handleCreateUser(db))
|
||||
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||
|
||||
// Your own account, or anybody's if you are an admin. The handlers call
|
||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||
// which rule applies depends on the {id} in the path.
|
||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||
|
||||
// Administration: who exists, and who is an administrator. Until #3
|
||||
// these were open to any authenticated caller, which meant every user
|
||||
// could delete every other one.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AdminOnly)
|
||||
|
||||
r.Post("/api/users", handleCreateUser(db))
|
||||
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
||||
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
||||
|
||||
// What exists on this server, and how it behaves. /api/teams
|
||||
// answers "what am I in"; this one answers "what is there".
|
||||
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
||||
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
||||
r.Put("/api/admin/settings", handleSetSettings(db))
|
||||
})
|
||||
|
||||
// Alerts are read-only: they are Alertmanager's record, not a work
|
||||
// queue. Everything a person does happens on the incident instead.
|
||||
r.Get("/api/alerts", handleListAlerts(db))
|
||||
@@ -60,10 +100,36 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
|
||||
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
||||
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
||||
|
||||
r.Post("/api/schedule", handleCreateSchedule(db))
|
||||
r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id}
|
||||
r.Get("/api/schedule", handleListSchedule(db))
|
||||
r.Delete("/api/schedule/{id}", handleDeleteSchedule(db))
|
||||
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||
r.Get("/api/teams", handleListTeams(db))
|
||||
r.Post("/api/teams", handleCreateTeam(db))
|
||||
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||
|
||||
// A team's escalation ladder: who is paged when nobody answers.
|
||||
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
||||
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||
|
||||
// A team's own dead man's switches: which of its alerts are heartbeats,
|
||||
// and how long a silence has to last before somebody is paged.
|
||||
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
|
||||
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db))
|
||||
|
||||
// Integrations: where a team's alerts come in, and the key that says so.
|
||||
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
||||
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
||||
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
||||
|
||||
// The rota is per team. /api/schedule/current is the exception: it
|
||||
// answers across every team the caller is in, which is what somebody on
|
||||
// two rotas wants to see.
|
||||
r.Get("/api/schedule/current", handleCurrentSchedule(db))
|
||||
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
|
||||
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
|
||||
r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db))
|
||||
|
||||
r.Get("/api/stats/incidents", handleStatsIncidents(db))
|
||||
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
||||
@@ -72,5 +138,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
|
||||
}
|
||||
|
||||
+75
-34
@@ -8,12 +8,22 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// The schedule is per team: each team keeps its own rota, so two teams can have
|
||||
// two different people on call on the same day. Editing it is an owner's job,
|
||||
// like the rest of a team's configuration; reading it is any member's.
|
||||
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Dates []string `json:"dates"`
|
||||
@@ -43,10 +53,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the user exists.
|
||||
// The person taking the shift has to be in the team: paging somebody
|
||||
// who cannot open the incident is worse than paging nobody.
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2",
|
||||
teamID, req.UserID).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusNotFound, errResp("user is not a member of this team"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,14 +77,16 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||
for _, d := range req.Dates {
|
||||
if req.Replace {
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"DELETE FROM schedule_entries WHERE date = ?", d); err != nil {
|
||||
"DELETE FROM schedule_entries WHERE team_id = $1 AND date = $2",
|
||||
teamID, d); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO schedule_entries (user_id, date) VALUES (?, ?)", req.UserID, d); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
"INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)",
|
||||
teamID, req.UserID, d); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict,
|
||||
errResp("date already assigned: "+d+" (pass replace to take it)"))
|
||||
return
|
||||
@@ -90,7 +105,7 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||
for _, d := range req.Dates {
|
||||
dateSet[d] = true
|
||||
}
|
||||
all, err := scheduleRange(r.Context(), db, req.Dates[0], req.Dates[len(req.Dates)-1])
|
||||
all, err := scheduleRange(r.Context(), db, teamID, req.Dates[0], req.Dates[len(req.Dates)-1])
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -107,6 +122,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
from, to := q.Get("from"), q.Get("to")
|
||||
|
||||
@@ -123,7 +145,7 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := scheduleRange(r.Context(), db, from, to)
|
||||
entries, err := scheduleRange(r.Context(), db, teamID, from, to)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -134,12 +156,20 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
|
||||
return
|
||||
}
|
||||
res, err := db.ExecContext(r.Context(), "DELETE FROM schedule_entries WHERE id = ?", id)
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM schedule_entries WHERE id = $1 AND team_id = $2", id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -152,55 +182,66 @@ func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleCurrentSchedule answers "who is on call right now" for every team the
|
||||
// caller belongs to — one entry per team, so somebody on two rotas sees both.
|
||||
// A team with nobody scheduled today simply does not appear.
|
||||
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
var e models.ScheduleEntry
|
||||
var ts int64
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
SELECT s.id, s.user_id, u.username, s.date, s.created_at
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||
FROM schedule_entries s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.date = ?`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts)
|
||||
if err == sql.ErrNoRows {
|
||||
respond(w, http.StatusNotFound, errResp("no one is on call today"))
|
||||
return
|
||||
}
|
||||
JOIN teams t ON t.id = s.team_id
|
||||
WHERE s.date = $1 AND s.team_id = ANY($2)
|
||||
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := []models.ScheduleEntry{}
|
||||
for rows.Next() {
|
||||
var e models.ScheduleEntry
|
||||
var ts int64
|
||||
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
respond(w, http.StatusOK, e)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleRange returns schedule entries ordered by date.
|
||||
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
|
||||
func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.ScheduleEntry, error) {
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
|
||||
args := &sqlArgs{}
|
||||
where := []string{"s.team_id = " + args.add(teamID)}
|
||||
if from != "" {
|
||||
where = append(where, "s.date >= ?")
|
||||
args = append(args, from)
|
||||
where = append(where, "s.date >= "+args.add(from))
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "s.date <= ?")
|
||||
args = append(args, to)
|
||||
where = append(where, "s.date <= "+args.add(to))
|
||||
}
|
||||
|
||||
clause := "1=1"
|
||||
if len(where) > 0 {
|
||||
clause = strings.Join(where, " AND ")
|
||||
}
|
||||
clause := strings.Join(where, " AND ")
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT s.id, s.user_id, u.username, s.date, s.created_at
|
||||
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||
FROM schedule_entries s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
JOIN teams t ON t.id = s.team_id
|
||||
WHERE `+clause+`
|
||||
ORDER BY s.date ASC`, args...)
|
||||
ORDER BY s.date ASC`, args.all()...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,7 +251,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S
|
||||
for rows.Next() {
|
||||
var e models.ScheduleEntry
|
||||
var ts int64
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// The settings an administrator can change at runtime. Each is behaviour rather
|
||||
// than infrastructure: what the server does, not where it is plugged in.
|
||||
//
|
||||
// The values are seconds, stored as text. A duration string would be friendlier
|
||||
// to read in psql and worse everywhere else — it can be stored unparseable, and
|
||||
// then the question is what a background loop should do at 02:00 with a
|
||||
// tuning knob it cannot understand.
|
||||
const (
|
||||
SettingNotifyRepeat = "notify_repeat_seconds"
|
||||
SettingStaleAfter = "stale_after_seconds"
|
||||
SettingArchiveAfter = "archive_after_seconds"
|
||||
)
|
||||
|
||||
// settingBounds keeps an edit from producing a server that cannot work. The
|
||||
// ceilings are loose — they exist to catch a slipped decimal point, not to have
|
||||
// an opinion about anybody's rota.
|
||||
var settingBounds = map[string]struct {
|
||||
min, max time.Duration
|
||||
label string
|
||||
}{
|
||||
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
|
||||
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
|
||||
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
|
||||
}
|
||||
|
||||
// Settings reads the runtime configuration. It holds no cache: the readers are
|
||||
// two background loops that tick every 30 seconds and 15 minutes, and handlers
|
||||
// that run once per request, so a query each time costs nothing measurable and
|
||||
// means an administrator's change takes effect on the next tick rather than at
|
||||
// the next restart.
|
||||
type Settings struct{ db *sql.DB }
|
||||
|
||||
// NewSettings returns a reader over db.
|
||||
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
|
||||
|
||||
// Duration reads one setting, falling back to def when the row is missing or
|
||||
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
|
||||
// is the value the server started with.
|
||||
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
|
||||
var raw string
|
||||
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// SeedSettings writes each key from the server's environment configuration,
|
||||
// once. Never overwrites: after the first start the database owns these, and a
|
||||
// redeploy must not put a chart's default back over an administrator's edit —
|
||||
// the same rule as the per-team dead man's switches.
|
||||
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
||||
seeds := map[string]time.Duration{
|
||||
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||
SettingStaleAfter: cfg.StaleAfter,
|
||||
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||
}
|
||||
for key, d := range seeds {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO NOTHING`,
|
||||
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settingsResponse is what the admin page renders. The environment half is
|
||||
// included and marked read-only, so somebody looking for the ntfy URL finds out
|
||||
// where it lives rather than concluding the server does not have one.
|
||||
type settingsResponse struct {
|
||||
Editable map[string]settingValue `json:"editable"`
|
||||
FromEnv map[string]string `json:"from_env"`
|
||||
}
|
||||
|
||||
type settingValue struct {
|
||||
Seconds int64 `json:"seconds"`
|
||||
Description string `json:"description"`
|
||||
MinSeconds int64 `json:"min_seconds"`
|
||||
MaxSeconds int64 `json:"max_seconds"`
|
||||
}
|
||||
|
||||
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
||||
settings := NewSettings(db)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out := settingsResponse{
|
||||
Editable: map[string]settingValue{},
|
||||
FromEnv: map[string]string{
|
||||
// Never the ntfy token or the DSN: both are credentials, and an
|
||||
// admin page that renders them turns a browser tab into a place
|
||||
// they leak from.
|
||||
"ntfy_url": cfg.NtfyURL,
|
||||
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
|
||||
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
|
||||
"public_url": cfg.PublicURL,
|
||||
"listen_address": cfg.Addr,
|
||||
},
|
||||
}
|
||||
for key, b := range settingBounds {
|
||||
def := map[string]time.Duration{
|
||||
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||
SettingStaleAfter: cfg.StaleAfter,
|
||||
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||
}[key]
|
||||
out.Editable[key] = settingValue{
|
||||
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
|
||||
Description: b.label,
|
||||
MinSeconds: int64(b.min.Seconds()),
|
||||
MaxSeconds: int64(b.max.Seconds()),
|
||||
}
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetSettings changes one or more settings. Unknown keys are refused
|
||||
// rather than stored: a typo that writes notify_repeat_second would otherwise
|
||||
// sit in the table looking like configuration and doing nothing.
|
||||
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]int64
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
if len(req) == 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("no settings given"))
|
||||
return
|
||||
}
|
||||
|
||||
for key, secs := range req {
|
||||
b, known := settingBounds[key]
|
||||
if !known {
|
||||
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||
return
|
||||
}
|
||||
d := time.Duration(secs) * time.Second
|
||||
if d < b.min || d > b.max {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
for key, secs := range req {
|
||||
if _, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ($1, $2, `+nowEpoch+`)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = excluded.value, updated_at = excluded.updated_at`,
|
||||
key, strconv.FormatInt(secs, 10)); 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)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminListTeams lists every team on the server, with its size. The
|
||||
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
||||
// which only an administrator may ask.
|
||||
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
FROM teams t
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type adminTeam struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
}
|
||||
teams := []adminTeam{}
|
||||
for rows.Next() {
|
||||
var t adminTeam
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
teams = append(teams, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
||||
// a team has nobody left to do it.
|
||||
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetUserDisabled takes an account out of use, or puts it back.
|
||||
//
|
||||
// Not a delete: the person's acknowledgements, assignments and timeline entries
|
||||
// stay attached to them. Deleting a user nulls those columns, which rewrites
|
||||
// what happened during an incident months after the fact.
|
||||
func handleSetUserDisabled(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
|
||||
respond(w, http.StatusBadRequest, errResp("disabled is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if *req.Disabled {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
if caller.ID == id {
|
||||
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
|
||||
return
|
||||
}
|
||||
last, err := isLastAdmin(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if last {
|
||||
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var res sql.Result
|
||||
if *req.Disabled {
|
||||
res, err = db.ExecContext(r.Context(),
|
||||
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
|
||||
} else {
|
||||
res, err = db.ExecContext(r.Context(),
|
||||
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Either no such user, or already in the state asked for. The
|
||||
// second is not a failure, so check which before answering.
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Signing back in is the only way to use a re-enabled account, and a
|
||||
// disabled one must not keep a live session.
|
||||
if *req.Disabled {
|
||||
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
|
||||
}
|
||||
|
||||
user, err := fetchUser(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// The settings an administrator can change, and the ones they cannot.
|
||||
func TestSettings_EditableAndReadOnly(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var got struct {
|
||||
Editable map[string]struct {
|
||||
Seconds int64 `json:"seconds"`
|
||||
Description string `json:"description"`
|
||||
MinSeconds int64 `json:"min_seconds"`
|
||||
MaxSeconds int64 `json:"max_seconds"`
|
||||
} `json:"editable"`
|
||||
FromEnv map[string]string `json:"from_env"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||
|
||||
// Seeded from the environment the server started with, not from zero.
|
||||
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
|
||||
t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
|
||||
}
|
||||
if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
|
||||
t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
|
||||
}
|
||||
if got.Editable["archive_after_seconds"].Description == "" {
|
||||
t.Error("a setting without a description is a number nobody can act on")
|
||||
}
|
||||
|
||||
// The environment half is visible so somebody can see where it lives, but
|
||||
// never the credentials themselves.
|
||||
if _, ok := got.FromEnv["public_url"]; !ok {
|
||||
t.Error("public_url should be reported as environment-configured")
|
||||
}
|
||||
for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
|
||||
if v, ok := got.FromEnv[leak]; ok {
|
||||
t.Errorf("%s must not be in the settings response (got %q)", leak, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changing a setting takes effect on the next tick, without a restart. This is
|
||||
// the whole point of moving them out of the environment.
|
||||
func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// An alert whose last webhook was two hours ago. Under the seeded
|
||||
// stale_after of six hours the sweeper leaves it alone.
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||
time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
|
||||
|
||||
sweep(t, s, noArchive)
|
||||
if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
|
||||
t.Fatalf("before the change the alert should still be firing, got %q", status)
|
||||
}
|
||||
|
||||
// Shorten it to an hour. Nothing restarts.
|
||||
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||
map[string]int64{"stale_after_seconds": 3600})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("change setting: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
sweep(t, s, noArchive)
|
||||
status, source, _ := s.alertRow(t, "fp-settings")
|
||||
if status != "resolved" {
|
||||
t.Errorf("after the change the alert should have expired, got %q", status)
|
||||
}
|
||||
if source == nil || *source != "expiry" {
|
||||
t.Errorf("expected resolution_source expiry, got %v", source)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo must not look like configuration, and a slipped decimal point must not
|
||||
// produce a server that sweeps every second.
|
||||
func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
body map[string]int64
|
||||
}{
|
||||
{"unknown key", map[string]int64{"notify_repeat_second": 60}},
|
||||
{"below the floor", map[string]int64{"stale_after_seconds": 30}},
|
||||
{"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
|
||||
{"nothing at all", map[string]int64{}},
|
||||
} {
|
||||
resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings are the server's behaviour, so only an administrator may change
|
||||
// them — or see where the rest of the configuration comes from.
|
||||
func TestSettings_AreAdminOnly(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, call := member(t, s, "member")
|
||||
|
||||
for _, c := range []struct {
|
||||
method string
|
||||
body any
|
||||
}{
|
||||
{http.MethodGet, nil},
|
||||
{http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
|
||||
} {
|
||||
resp := call(c.method, "/api/admin/settings", c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
resp := call(http.MethodGet, "/api/admin/teams", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An administrator sees every team, including ones they are not in — which is
|
||||
// exactly what /api/teams must not show them.
|
||||
func TestSettings_AdminSeesEveryTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
newTeam(t, s, "red")
|
||||
newTeam(t, s, "blue")
|
||||
|
||||
all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||
if len(all) != 3 { // Default, red, blue
|
||||
t.Fatalf("admin should see all 3 teams, saw %d", len(all))
|
||||
}
|
||||
for _, team := range all {
|
||||
if _, ok := team["members"]; !ok {
|
||||
t.Error("the admin listing should say how big each team is")
|
||||
}
|
||||
}
|
||||
|
||||
// The admin created them, so they own them — but they are not a member of
|
||||
// a team somebody else makes, and /api/teams still answers "what am I in".
|
||||
mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
|
||||
if len(mine) != 3 {
|
||||
t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling is not deleting: the account stops working and the history stays.
|
||||
func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "leaver")
|
||||
|
||||
// They acknowledge an incident, so there is history to preserve.
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("acknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||
map[string]bool{"disabled": true})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("disable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Their API key stops working.
|
||||
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The acknowledgement still names them.
|
||||
var incident map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
|
||||
if incident["acknowledged_by"] != "leaver" {
|
||||
t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
|
||||
}
|
||||
if incident["status"] != "acknowledged" {
|
||||
t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
|
||||
}
|
||||
|
||||
// And re-enabling gives the account back.
|
||||
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||
map[string]bool{"disabled": false})
|
||||
resp.Body.Close()
|
||||
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The same two guards as deleting and demoting: an install must keep somebody
|
||||
// who can administer it.
|
||||
func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming a team is an owner's job, and the name stays unique.
|
||||
func TestSettings_TeamRename(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("rename: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||
found := false
|
||||
for _, x := range teams {
|
||||
if x["name"] == "Platform" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the renamed team should be listed under its new name")
|
||||
}
|
||||
|
||||
// Taking a name that exists is a conflict, not a silent second team with
|
||||
// the same label.
|
||||
resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The seed runs once. A redeploy must not put the chart's default back over an
|
||||
// administrator's edit — the rule the dead man's switches already follow.
|
||||
func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||
map[string]int64{"notify_repeat_seconds": 60})
|
||||
resp.Body.Close()
|
||||
|
||||
// A second start, with the environment still saying 15 minutes.
|
||||
if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
|
||||
t.Fatalf("re-seed: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Editable map[string]struct {
|
||||
Seconds int64 `json:"seconds"`
|
||||
} `json:"editable"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
|
||||
t.Errorf("the edit should survive a restart, got %d", v)
|
||||
}
|
||||
}
|
||||
+34
-26
@@ -11,14 +11,17 @@ import (
|
||||
|
||||
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
||||
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||
|
||||
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
|
||||
// nothing is 0 — without it an empty window is a 500 rather than a
|
||||
// legitimately empty report.
|
||||
var total, firing, resolved int64
|
||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END)
|
||||
FROM alerts WHERE %s`, where), args...,
|
||||
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.all()...,
|
||||
).Scan(&total, &firing, &resolved)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -34,7 +37,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
||||
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||
|
||||
limit := 10
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
@@ -44,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
|
||||
@@ -78,15 +79,15 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
||||
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT CAST(strftime('%%H', datetime(received_at, 'unixepoch')) AS INTEGER) AS hr,
|
||||
SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr,
|
||||
COUNT(*) AS cnt
|
||||
FROM alerts
|
||||
WHERE %s
|
||||
GROUP BY hr
|
||||
ORDER BY hr ASC`, where), args...)
|
||||
ORDER BY hr ASC`, where), args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -118,16 +119,17 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
||||
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||
|
||||
// SQLite strftime('%w') → 0=Sunday … 6=Saturday
|
||||
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering
|
||||
// SQLite's strftime('%w') returned, so the frontend needs no change.
|
||||
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT CAST(strftime('%%w', datetime(received_at, 'unixepoch')) AS INTEGER) AS dow,
|
||||
SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow,
|
||||
COUNT(*) AS cnt
|
||||
FROM alerts
|
||||
WHERE %s
|
||||
GROUP BY dow
|
||||
ORDER BY dow ASC`, where), args...)
|
||||
ORDER BY dow ASC`, where), args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -165,20 +167,23 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||
// mutated in place and carry no acknowledgement or closure time.
|
||||
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "triggered_at")
|
||||
where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context()))
|
||||
|
||||
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
|
||||
// The averages are not: mtta and mttr stay null on purpose, since zero
|
||||
// would read as "instant" rather than "nothing to measure yet".
|
||||
var total, triggered, acknowledged, resolved int64
|
||||
var mtta, mttr *float64
|
||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN acknowledged_at IS NOT NULL
|
||||
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"))
|
||||
@@ -201,18 +206,21 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
||||
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
|
||||
// params, filtering on timeCol. Archived rows are always excluded, matching the
|
||||
// default list views.
|
||||
func statsFilter(q url.Values, timeCol string) (where string, args []any) {
|
||||
clauses := []string{"archived_at IS NULL"}
|
||||
//
|
||||
// teamIDs scopes every figure to the caller's own teams: a report that counted
|
||||
// other teams' incidents would leak their volume and their names through the
|
||||
// top-alerts list, and would not be a number about the reader's work anyway.
|
||||
func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) {
|
||||
args = &sqlArgs{}
|
||||
clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"}
|
||||
if from := q.Get("from"); from != "" {
|
||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||
clauses = append(clauses, timeCol+" >= ?")
|
||||
args = append(args, t.UTC().Unix())
|
||||
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
|
||||
}
|
||||
}
|
||||
if to := q.Get("to"); to != "" {
|
||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||
clauses = append(clauses, timeCol+" < ?")
|
||||
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
||||
clauses = append(clauses, timeCol+" < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
||||
}
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// handleListTeams lists the caller's own teams, each with their role in it. An
|
||||
// administrator listing every team goes through the admin endpoint instead:
|
||||
// this one answers "what am I part of", which is what the UI's team filter and
|
||||
// the combined queue are built from.
|
||||
func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.name`, caller.ID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
teams := []models.Team{}
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
teams = append(teams, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||
// with no owner would need an administrator to repair before anybody could use
|
||||
// it, so the two happen in one transaction.
|
||||
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
caller, _ := userFromContext(r.Context())
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var team models.Team
|
||||
var created int64
|
||||
if err := tx.QueryRowContext(r.Context(),
|
||||
"INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at",
|
||||
req.Name).Scan(&team.ID, &team.Name, &created); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||
team.ID, caller.ID, models.RoleOwner); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||
team.Role = models.RoleOwner
|
||||
respond(w, http.StatusCreated, team)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteTeam removes a team and, by cascade, its incidents, alerts,
|
||||
// schedule and integrations.
|
||||
//
|
||||
// Refused while the team still has open incidents: deleting a team is tidying
|
||||
// up, and tidying up should never be how an unacknowledged page disappears.
|
||||
// Resolve or archive them first, deliberately.
|
||||
func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var open int
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID).
|
||||
Scan(&open); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if open > 0 {
|
||||
respond(w, http.StatusConflict, errResp("team still has open incidents"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListTeamMembers names everybody in a team. Visible to any member: you
|
||||
// can see who else is on the rota you are on.
|
||||
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
||||
FROM team_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.team_id = $1
|
||||
ORDER BY u.username`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []models.TeamMember{}
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
var joined int64
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
members = append(members, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, members)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddTeamMember adds a user to a team, or changes the role of somebody
|
||||
// already in it.
|
||||
func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.UserID == 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("user_id is required"))
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = models.RoleMember
|
||||
}
|
||||
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
teamID, req.UserID, req.Role)
|
||||
if err != nil {
|
||||
// The only foreign key that can fail here is the user: the team was
|
||||
// resolved from the caller's own membership.
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRemoveTeamMember takes a user out of a team.
|
||||
//
|
||||
// A team must keep an owner, for the same reason the install must keep an
|
||||
// administrator: otherwise nobody can configure it, and repairing that needs
|
||||
// somebody with more access than the team has.
|
||||
func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
|
||||
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if last {
|
||||
respond(w, http.StatusConflict, errResp("cannot remove the last owner of a team"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not a member of this team"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||
var last bool
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM team_members
|
||||
WHERE team_id = $1 AND user_id = $2 AND role = 'owner')
|
||||
AND NOT EXISTS (SELECT 1 FROM team_members
|
||||
WHERE team_id = $1 AND user_id <> $2 AND role = 'owner')`,
|
||||
teamID, userID).Scan(&last)
|
||||
return last, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleListIntegrations lists a team's integrations. Never the keys: those
|
||||
// exist in plaintext only in the response that created them.
|
||||
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT id, team_id, kind, name, created_at, last_used_at
|
||||
FROM integrations
|
||||
WHERE team_id = $1
|
||||
ORDER BY id`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
integrations := []models.Integration{}
|
||||
for rows.Next() {
|
||||
var i models.Integration
|
||||
var created int64
|
||||
var lastUsed *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.LastUsedAt = unixPtr(lastUsed)
|
||||
integrations = append(integrations, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, integrations)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateIntegration mints an integration key. The key is returned once,
|
||||
// in this response, and only its hash is kept — the same handling as an API key
|
||||
// or an acknowledgement token.
|
||||
func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if req.Kind == "" {
|
||||
req.Kind = models.IntegrationAlertmanager
|
||||
}
|
||||
if req.Kind != models.IntegrationAlertmanager {
|
||||
respond(w, http.StatusBadRequest, errResp("unsupported integration kind"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, hash, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
var i models.Integration
|
||||
var created int64
|
||||
if err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO integrations (team_id, kind, name, key_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, team_id, kind, name, created_at`,
|
||||
teamID, req.Kind, req.Name, hash).
|
||||
Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.Key = raw
|
||||
i.URL = strings.TrimSuffix(publicURL, "/") + integrationPath(raw, i.Kind)
|
||||
respond(w, http.StatusCreated, i)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM integrations WHERE id = $1 AND team_id = $2", id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// integrationPath is where a sender of this kind posts. Built in one place so
|
||||
// the URL handed out at creation and the route the router registers cannot
|
||||
// drift apart.
|
||||
func integrationPath(key, kind string) string {
|
||||
return "/api/integrations/" + key + "/" + kind
|
||||
}
|
||||
|
||||
// teamIDForKey resolves an integration key to its team, and stamps the key's
|
||||
// last use. An unknown key is not an error worth distinguishing: the caller is
|
||||
// told nothing beyond "no".
|
||||
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
|
||||
var teamID int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errUnknownIntegration
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
||||
db.ExecContext(ctx, //nolint:errcheck
|
||||
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
|
||||
time.Now().Unix(), hashToken(key))
|
||||
return teamID, nil
|
||||
}
|
||||
|
||||
var errUnknownIntegration = errors.New("unknown integration key")
|
||||
|
||||
// teamParam reads {teamID} from the path.
|
||||
func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "teamID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid team id"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// defaultTeamID is the oldest team, which on an upgraded install is the
|
||||
// "Default" team every pre-teams row was moved into and on a fresh one is the
|
||||
// team migration 003 creates. Bootstrap puts the first user in it, so somebody
|
||||
// signing in to a new server lands somewhere rather than in no team at all.
|
||||
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var id int64
|
||||
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A team's dead man's switches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// deadmanResponse is the wire shape of a team's switch configuration. The
|
||||
// timeout is seconds rather than a duration string, because that is what the
|
||||
// column holds and what arithmetic is done on; a client renders it.
|
||||
type deadmanResponse struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
Matchers string `json:"matchers"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
out := deadmanResponse{TeamID: teamID, Severity: "critical"}
|
||||
err := db.QueryRowContext(r.Context(),
|
||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
||||
teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
// A team with no row watches nothing, which is a configuration and not
|
||||
// an absence: answering 404 would make "off" indistinguishable from
|
||||
// "this server does not do this".
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetTeamDeadman replaces a team's switch configuration.
|
||||
//
|
||||
// Validated by parsing: a matcher string that survives ParseDeadmanConfig with
|
||||
// nothing usable in it is rejected rather than stored, because a switch that
|
||||
// silently watches nothing is the failure this feature exists to prevent.
|
||||
func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Matchers string `json:"matchers"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Matchers = strings.TrimSpace(req.Matchers)
|
||||
if req.Severity == "" {
|
||||
req.Severity = "critical"
|
||||
}
|
||||
if req.TimeoutSeconds < 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative"))
|
||||
return
|
||||
}
|
||||
if req.Matchers != "" {
|
||||
parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity)
|
||||
if len(parsed.Matchers) == 0 {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
"no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
|
||||
ON CONFLICT (team_id) DO UPDATE SET
|
||||
matchers = excluded.matchers,
|
||||
timeout_seconds = excluded.timeout_seconds,
|
||||
severity = excluded.severity,
|
||||
updated_at = excluded.updated_at`,
|
||||
teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, deadmanResponse{
|
||||
TeamID: teamID,
|
||||
Matchers: req.Matchers,
|
||||
TimeoutSeconds: req.TimeoutSeconds,
|
||||
Severity: req.Severity,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The whole point of #4: two teams sharing one server must not see each other's
|
||||
// work. These tests build two of them and check the boundary from both sides.
|
||||
|
||||
type teamFixture struct {
|
||||
id int64
|
||||
key string // integration key: how alerts get in
|
||||
call func(method, path string, body any) *http.Response
|
||||
}
|
||||
|
||||
// newTeam creates a team with its own member, integration key and API key. The
|
||||
// admin does the creating, as an install's first user would.
|
||||
func newTeam(t *testing.T, s *ts, name string) teamFixture {
|
||||
t.Helper()
|
||||
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": name}), &team)
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/integrations",
|
||||
map[string]string{"name": name + " alertmanager"}), &integration)
|
||||
if integration.Key == "" {
|
||||
t.Fatalf("%s: integration key was not returned", name)
|
||||
}
|
||||
|
||||
// A member of this team and no other.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": name + "-user", "email": name + "@test.com"}), &user)
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "owner"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("%s: add member: %d", name, resp.StatusCode)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
return teamFixture{
|
||||
id: team.ID,
|
||||
key: integration.Key,
|
||||
call: func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postToIntegration sends one firing alert on a team's integration key, the way
|
||||
// a real Alertmanager receiver would.
|
||||
func postToIntegration(t *testing.T, s *ts, key, fingerprint, name string) {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"version": "4",
|
||||
"status": "firing",
|
||||
"groupKey": "{}:{alertname=\"" + name + "\"}",
|
||||
"groupLabels": map[string]string{"alertname": name},
|
||||
"alerts": []map[string]any{
|
||||
amAlert(fingerprint, name, "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+key+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post alert: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("post alert: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func list(t *testing.T, resp *http.Response) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
decode(t, resp, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// An alert posted on one team's key opens an incident in that team and nowhere
|
||||
// else, and neither team can read the other's queue.
|
||||
func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue", "BlueDiskFull")
|
||||
|
||||
redIncidents := list(t, red.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(redIncidents) != 1 {
|
||||
t.Fatalf("red should see exactly its own incident, saw %d", len(redIncidents))
|
||||
}
|
||||
if title := redIncidents[0]["title"]; title != "RedDiskFull" {
|
||||
t.Errorf("red saw %v", title)
|
||||
}
|
||||
if teamID := int64(redIncidents[0]["team_id"].(float64)); teamID != red.id {
|
||||
t.Errorf("red's incident belongs to team %d, want %d", teamID, red.id)
|
||||
}
|
||||
|
||||
blueIncidents := list(t, blue.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(blueIncidents) != 1 || blueIncidents[0]["title"] != "BlueDiskFull" {
|
||||
t.Fatalf("blue should see exactly its own incident, saw %v", blueIncidents)
|
||||
}
|
||||
|
||||
// Reading the other team's incident by id is not found rather than
|
||||
// forbidden: its existence is the other team's business.
|
||||
otherID := int64(blueIncidents[0]["id"].(float64))
|
||||
for _, path := range []string{
|
||||
"/api/incidents/" + id64(otherID),
|
||||
"/api/incidents/" + id64(otherID) + "/alerts",
|
||||
"/api/incidents/" + id64(otherID) + "/timeline",
|
||||
} {
|
||||
resp := red.call(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("red reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// And cannot act on it either.
|
||||
for _, path := range []string{"/acknowledge", "/resolve", "/archive"} {
|
||||
resp := red.call(http.MethodPost, "/api/incidents/"+id64(otherID)+path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("red posting %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts, the raw signal record, are scoped the same way.
|
||||
func TestTeams_AlertsAndStatsAreScoped(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue-1", "BlueDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue-2", "BlueMemory")
|
||||
|
||||
if alerts := list(t, red.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 1 {
|
||||
t.Errorf("red should see 1 alert, saw %d", len(alerts))
|
||||
}
|
||||
if alerts := list(t, blue.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 2 {
|
||||
t.Errorf("blue should see 2 alerts, saw %d", len(alerts))
|
||||
}
|
||||
|
||||
// Statistics count your own work only — otherwise a team's volume, and the
|
||||
// names of its alerts, leak through the totals.
|
||||
var stats map[string]any
|
||||
decode(t, red.call(http.MethodGet, "/api/stats/alerts", nil), &stats)
|
||||
if total := stats["total"].(float64); total != 1 {
|
||||
t.Errorf("red's alert stats counted %v alerts, want 1", total)
|
||||
}
|
||||
|
||||
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
|
||||
for _, row := range top {
|
||||
if name := row["name"].(string); name != "RedDiskFull" {
|
||||
t.Errorf("red's top alerts named %q, which is not theirs", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same fingerprint, the same groupKey and the same date are all legitimate
|
||||
// in two teams at once: two clusters running the same rules, two rotas.
|
||||
func TestTeams_SameFingerprintInTwoTeams(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-shared", "DiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-shared", "DiskFull")
|
||||
|
||||
for _, team := range []struct {
|
||||
name string
|
||||
f teamFixture
|
||||
}{{"red", red}, {"blue", blue}} {
|
||||
incidents := list(t, team.f.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(incidents) != 1 {
|
||||
t.Errorf("%s: expected its own incident for the shared fingerprint, saw %d",
|
||||
team.name, len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// And both rotas can name somebody for the same day.
|
||||
for _, team := range []struct {
|
||||
name string
|
||||
f teamFixture
|
||||
}{{"red", red}, {"blue", blue}} {
|
||||
var members []map[string]any
|
||||
decode(t, team.f.call(http.MethodGet, "/api/teams/"+id64(team.f.id)+"/members", nil), &members)
|
||||
userID := int64(members[0]["user_id"].(float64))
|
||||
|
||||
resp := team.f.call(http.MethodPost, "/api/teams/"+id64(team.f.id)+"/schedule",
|
||||
map[string]any{"user_id": userID, "dates": []string{"2026-10-01"}})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("%s: taking 2026-10-01 returned %d", team.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key delivers nothing, and says so rather than accepting silently.
|
||||
func TestTeams_UnknownIntegrationKeyIsRejected(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/not-a-real-key/alertmanager",
|
||||
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for an unknown key, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if incidents := list(t, team.call(http.MethodGet, "/api/incidents", nil)); len(incidents) != 0 {
|
||||
t.Errorf("a rejected payload opened %d incident(s)", len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// Team configuration is an owner's job; working incidents is a member's.
|
||||
func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
// A plain member of the same team.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
call := func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
base := "/api/teams/" + id64(team.id)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{"mint an integration key", http.MethodPost, base + "/integrations",
|
||||
map[string]string{"name": "mine"}},
|
||||
{"take a shift", http.MethodPost, base + "/schedule",
|
||||
map[string]any{"user_id": user.ID, "dates": []string{"2026-11-01"}}},
|
||||
{"add a member", http.MethodPost, base + "/members",
|
||||
map[string]any{"user_id": 1}},
|
||||
{"delete the team", http.MethodDelete, base, nil},
|
||||
} {
|
||||
resp := call(c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// But they can read what the team is doing.
|
||||
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||
resp := call(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("reading %s: expected 200, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
base := "/api/teams/" + id64(red.id)
|
||||
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||
resp := blue.call(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("blue reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// /api/teams lists your own, never the install's.
|
||||
teams := list(t, blue.call(http.MethodGet, "/api/teams", nil))
|
||||
if len(teams) != 1 || teams[0]["name"] != "blue" {
|
||||
t.Errorf("blue's team list: %v", teams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
// Tests run against a real Postgres, because the server does. SQLite's
|
||||
// ":memory:" gave every test a private database for free; Postgres has no
|
||||
// equivalent, so isolation is bought with a schema per test.
|
||||
//
|
||||
// A schema rather than a database: CREATE DATABASE copies a template on disk and
|
||||
// costs a hundred milliseconds or so each time, while CREATE SCHEMA plus the one
|
||||
// baseline migration is a few, and the suite runs a few hundred of them. Each
|
||||
// test's pool is pinned to its own schema through search_path, so two tests
|
||||
// cannot see each other's rows even though they share a server.
|
||||
//
|
||||
// TERDUT_TEST_DSN must point at a database the test role may create schemas in:
|
||||
//
|
||||
// postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable
|
||||
//
|
||||
// `make test-db` starts one locally; ci.yaml runs one as a service container.
|
||||
// An unset DSN fails rather than skips, deliberately — a suite that quietly
|
||||
// tests nothing is worse than one that does not run.
|
||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||
|
||||
// testConfig is the environment half of the server's configuration, which the
|
||||
// admin settings page renders read-only and SeedSettings seeds the editable
|
||||
// half from. The durations match the defaults config.Load would produce, so a
|
||||
// test that never touches the settings table behaves as a fresh install does.
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
Addr: ":8080",
|
||||
ArchiveAfter: 7 * 24 * time.Hour,
|
||||
StaleAfter: 6 * time.Hour,
|
||||
NotifyRepeat: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
|
||||
// a path segment. Every test that does not say otherwise works inside it.
|
||||
const defaultTeam = "1"
|
||||
|
||||
var schemaSeq int
|
||||
|
||||
// newTestDB returns a migrated database private to this test, and drops it
|
||||
// afterwards.
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv(testDSNEnv)
|
||||
if dsn == "" {
|
||||
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
||||
"Run `make test-db` for a local one, then\n"+
|
||||
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
||||
testDSNEnv, testDSNEnv)
|
||||
}
|
||||
|
||||
schemaSeq++
|
||||
schema := fmt.Sprintf("test_%d_%d", os.Getpid(), schemaSeq)
|
||||
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
|
||||
database, err := db.Open(withSearchPath(dsn, schema))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
database.Close()
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
// withSearchPath pins a DSN to one schema, so every connection the pool opens
|
||||
// lands there and nothing has to qualify a table name.
|
||||
//
|
||||
// Handles both DSN spellings: a postgres:// URL, and libpq's keyword/value form.
|
||||
func withSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
+146
-26
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||
@@ -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,36 @@ 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, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
|
||||
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
userID, _ := res.LastInsertId()
|
||||
|
||||
raw, hash, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
keyRes, err := db.ExecContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, "bootstrap")
|
||||
if err != nil {
|
||||
var keyID int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
userID, hash, "bootstrap").Scan(&keyID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
keyID, _ := keyRes.LastInsertId()
|
||||
|
||||
// The default team exists from migration 003, on a fresh install too.
|
||||
// Without a membership the first user signs in to a working server with
|
||||
// no queue, no schedule and nowhere for an integration to hang off.
|
||||
if teamID, err := defaultTeamID(r.Context(), db); err == nil {
|
||||
db.ExecContext(r.Context(), //nolint:errcheck
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+
|
||||
"ON CONFLICT (team_id, user_id) DO NOTHING",
|
||||
teamID, userID, models.RoleOwner)
|
||||
}
|
||||
|
||||
user, _ := fetchUser(r.Context(), db, userID)
|
||||
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
||||
@@ -70,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
"SELECT id, username, email, created_at, ntfy_topic FROM users ORDER BY id")
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -81,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var ts int64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic); err != nil {
|
||||
var disabled *int64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
u.DisabledAt = unixPtr(disabled)
|
||||
users = append(users, u)
|
||||
}
|
||||
respond(w, http.StatusOK, users)
|
||||
@@ -107,17 +135,17 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
var id int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
|
||||
req.Username, req.Email).Scan(&id); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
user, _ := fetchUser(r.Context(), db, id)
|
||||
respond(w, http.StatusCreated, user)
|
||||
}
|
||||
@@ -134,6 +162,9 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, id) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
NtfyTopic string `json:"ntfy_topic"`
|
||||
}
|
||||
@@ -148,7 +179,7 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE users SET ntfy_topic = ? WHERE id = ?", topic, id)
|
||||
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -174,7 +205,22 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = ?", id)
|
||||
// Deleting yourself is how an install ends up with no administrator at
|
||||
// all, and it is never what somebody meant to do.
|
||||
caller, _ := userFromContext(r.Context())
|
||||
if caller.ID == id {
|
||||
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
|
||||
return
|
||||
}
|
||||
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if last {
|
||||
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -195,6 +241,9 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -209,7 +258,7 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", userID).Scan(&exists); err != nil {
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
@@ -219,13 +268,13 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, req.Name)
|
||||
if err != nil {
|
||||
var keyID int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
userID, hash, req.Name).Scan(&keyID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
keyID, _ := res.LastInsertId()
|
||||
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||
respond(w, http.StatusCreated, key)
|
||||
}
|
||||
@@ -238,6 +287,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, userID) {
|
||||
return
|
||||
}
|
||||
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
||||
@@ -245,7 +297,7 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM api_keys WHERE id = ? AND user_id = ?", keyID, userID)
|
||||
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -275,12 +327,80 @@ func randomToken() (raw, hash string, err error) {
|
||||
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||
var u models.User
|
||||
var ts int64
|
||||
var disabled *int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = ?", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic)
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
u.DisabledAt = unixPtr(disabled)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// handleSetAdmin grants or revokes the system administrator flag.
|
||||
//
|
||||
// Revoking is guarded twice: an install must keep at least one administrator,
|
||||
// and you cannot demote yourself. The first stops the flag being lost
|
||||
// altogether; the second stops the likelier accident, where the only admin
|
||||
// clears their own flag while tidying up and locks the door behind them.
|
||||
func handleSetAdmin(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
|
||||
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if !*req.IsAdmin {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
if caller.ID == id {
|
||||
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
||||
return
|
||||
}
|
||||
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if last {
|
||||
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := fetchUser(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
// isLastAdmin reports whether id is an administrator and no other user is one.
|
||||
// A non-admin id is never the last one, so removing them is always allowed.
|
||||
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
|
||||
var last bool
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
|
||||
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
|
||||
return last, err
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- A system administrator role, and the first thing in this server that one user
|
||||
-- can do and another cannot.
|
||||
--
|
||||
-- Until now every authenticated caller could create and delete users, set
|
||||
-- anybody's password and mint API keys for anybody — auth.go said so in a
|
||||
-- comment. That was defensible with one operator and a hand-made account; it is
|
||||
-- not once people sign themselves up (see #7).
|
||||
--
|
||||
-- EVERY EXISTING USER BECOMES AN ADMIN. They already hold these powers, so
|
||||
-- this migration changes nobody's access: it names what is already true, and
|
||||
-- leaves demotion as a deliberate act somebody performs afterwards. The
|
||||
-- alternative — promoting only user 1 — would silently strip the others, and
|
||||
-- could leave an install whose only admin is an account nobody has a password
|
||||
-- for.
|
||||
--
|
||||
-- New users are not admins: the column defaults to false, and the only ways to
|
||||
-- become one are this backfill, the bootstrap endpoint, or an existing admin
|
||||
-- granting it.
|
||||
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE users SET is_admin = true;
|
||||
|
||||
-- The queue's assignment dropdown and the on-call schedule read every user, and
|
||||
-- the admin screens in #5 will filter on this.
|
||||
CREATE INDEX users_is_admin_idx ON users(is_admin) WHERE is_admin;
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
last_used_at INTEGER
|
||||
);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Teams: the unit of tenancy. Everything a person works on now belongs to one.
|
||||
--
|
||||
-- Until this migration the install was one shared space — every user saw every
|
||||
-- alert and every incident, and the Alertmanager webhook was unauthenticated, so
|
||||
-- anything that could reach the port could open an incident for everybody.
|
||||
--
|
||||
-- The shape, in one paragraph: a team owns its incidents, alerts, schedule and
|
||||
-- integrations. A user belongs to as many teams as they like, with a role in
|
||||
-- each: an `owner` configures the team, a `member` works its incidents. An
|
||||
-- integration key is what an alert arrives on, and the key is what says which
|
||||
-- team the alert belongs to.
|
||||
--
|
||||
-- EVERYTHING EXISTING MOVES INTO ONE DEFAULT TEAM, and every existing user
|
||||
-- becomes an owner of it. That keeps an upgrade a no-op for the people using it:
|
||||
-- the same queue, the same schedule, the same incidents, with a name on them.
|
||||
|
||||
CREATE TABLE teams (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
);
|
||||
|
||||
-- role is free text with a CHECK rather than an enum, so adding a third role
|
||||
-- later is a migration and not a type rewrite.
|
||||
CREATE TABLE team_members (
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||
joined_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX team_members_user_idx ON team_members(user_id);
|
||||
|
||||
-- How alerts get in, and the only thing that says which team they belong to.
|
||||
-- The key is stored as a SHA-256 hash, like api_keys and the ack tokens: a
|
||||
-- leaked database gives nobody the ability to post alerts.
|
||||
CREATE TABLE integrations (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('alertmanager')),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
last_used_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX integrations_team_idx ON integrations(team_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The default team, and everything that already exists moving into it.
|
||||
--
|
||||
-- Created unconditionally, even on an empty install, so there is always a team
|
||||
-- for the bootstrap user to land in and for the first integration to hang off.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO teams (name) VALUES ('Default');
|
||||
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
SELECT (SELECT id FROM teams WHERE name = 'Default'), id, 'owner' FROM users;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- team_id on everything a team owns.
|
||||
--
|
||||
-- Added nullable, backfilled, then made NOT NULL: adding a NOT NULL column with
|
||||
-- no default to a table with rows is rejected, and a DEFAULT pointing at the
|
||||
-- default team would quietly keep working after the default team is gone.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE alerts ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||
ALTER TABLE incidents ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||
ALTER TABLE schedule_entries ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||
|
||||
UPDATE alerts SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||
UPDATE incidents SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||
UPDATE schedule_entries SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||
|
||||
ALTER TABLE alerts ALTER COLUMN team_id SET NOT NULL;
|
||||
ALTER TABLE incidents ALTER COLUMN team_id SET NOT NULL;
|
||||
ALTER TABLE schedule_entries ALTER COLUMN team_id SET NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The uniqueness rules were all written for one tenant, and every one of them
|
||||
-- is wrong now: two teams monitoring two clusters legitimately see the same
|
||||
-- fingerprint, the same groupKey, and want somebody on call on the same day.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE alerts DROP CONSTRAINT alerts_fingerprint_key;
|
||||
CREATE UNIQUE INDEX alerts_team_fingerprint_idx ON alerts(team_id, fingerprint);
|
||||
|
||||
DROP INDEX incidents_open_group_key_idx;
|
||||
-- Still load-bearing, now per team: at most one OPEN incident per group_key
|
||||
-- within a team. This is what makes "resolved incident + a new alert occurrence
|
||||
-- = a new incident" work, and what the webhook's find-or-open lookup relies on.
|
||||
CREATE UNIQUE INDEX incidents_open_group_key_idx
|
||||
ON incidents(team_id, group_key) WHERE resolved_at IS NULL;
|
||||
|
||||
ALTER TABLE schedule_entries DROP CONSTRAINT schedule_entries_date_key;
|
||||
CREATE UNIQUE INDEX schedule_entries_team_date_idx ON schedule_entries(team_id, date);
|
||||
|
||||
-- The list views all filter by team first.
|
||||
CREATE INDEX alerts_team_received_idx ON alerts(team_id, received_at DESC);
|
||||
CREATE INDEX incidents_team_triggered_idx ON incidents(team_id, triggered_at DESC);
|
||||
@@ -1,12 +0,0 @@
|
||||
ALTER TABLE alerts ADD COLUMN acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE alerts ADD COLUMN acknowledged_at INTEGER;
|
||||
|
||||
CREATE TABLE alert_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX alert_comments_alert_id_idx ON alert_comments(alert_id);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Dead man's switches become a team's own configuration.
|
||||
--
|
||||
-- They were three environment variables — TERDUT_DEADMAN_MATCHERS, _TIMEOUT and
|
||||
-- _SEVERITY — which made them one setting for the whole install. That was the
|
||||
-- last piece of the alerting path a team could not control: a team could take
|
||||
-- its own alerts on its own key and still not say which of them were
|
||||
-- heartbeats, or how long a silence had to last before somebody was paged.
|
||||
--
|
||||
-- One row per team rather than one row per switch. The unit of monitoring is
|
||||
-- still the fingerprint, as it always was — two clusters sending the same
|
||||
-- heartbeat alertname are two independent switches — and the matcher string
|
||||
-- keeps the format the environment variable used, so a value can be moved from
|
||||
-- one to the other unchanged.
|
||||
--
|
||||
-- No rows are seeded here: a migration cannot read the environment. The server
|
||||
-- inserts a row per team at startup from its own configuration, and the same
|
||||
-- values therefore carry forward into the first team's row without anybody
|
||||
-- retyping them. See seedDeadmanConfigs.
|
||||
CREATE TABLE deadman_configs (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- ";" separates matchers, "," the label conditions within one, "=" is exact
|
||||
-- equality: `alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat`.
|
||||
-- Every matcher must name an alertname. Empty watches nothing.
|
||||
matchers TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Seconds rather than a Go duration string: the column is compared and
|
||||
-- arithmetic is done on it, and a value that has to be parsed before it can
|
||||
-- be believed is a value that can be stored unparseable. Zero disables the
|
||||
-- team's switches entirely.
|
||||
timeout_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- The severity these incidents open at. They have no member alerts to
|
||||
-- derive one from, and a heartbeat's own severity label is meaningless —
|
||||
-- Watchdog ships as "none".
|
||||
severity TEXT NOT NULL DEFAULT 'critical',
|
||||
|
||||
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Settings that an administrator can change without a redeploy, and the flag
|
||||
-- that takes an account out of use without deleting it.
|
||||
--
|
||||
-- Three of the server's tunables were environment variables, which meant
|
||||
-- changing how long an incident waits before it is paged again required editing
|
||||
-- a chart, merging it, and waiting for a reconcile. They are behaviour, not
|
||||
-- infrastructure, and the difference is who needs to change them and how often.
|
||||
--
|
||||
-- What stays in the environment: the ntfy URL and token, the database DSN, the
|
||||
-- listen address and the public URL. Those are where the server is plugged in
|
||||
-- rather than how it behaves, they are needed before the database is open, and
|
||||
-- two of them are credentials.
|
||||
--
|
||||
-- Key/value rather than a column per setting. A settings table with one row and
|
||||
-- a column per knob needs a migration for every new knob, and #6 and #7 will
|
||||
-- both add some. The cost is that values are text and the accessor has to say
|
||||
-- what type it wanted; settings.go does that in one place.
|
||||
--
|
||||
-- No rows are seeded here: a migration cannot read the environment. The server
|
||||
-- inserts each key from its own configuration at startup, once, so an install
|
||||
-- that upgrades keeps exactly the behaviour it had. See SeedSettings.
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
);
|
||||
|
||||
-- Disabling an account rather than deleting it: the person has left, or the
|
||||
-- credential is suspect, and their incidents, acknowledgements and timeline
|
||||
-- entries must stay exactly where they are. Deleting a user nulls their
|
||||
-- acknowledged_by and assigned_to, which quietly rewrites history.
|
||||
--
|
||||
-- A disabled user cannot sign in and their API keys stop working, but they are
|
||||
-- still a name the timeline can show and still a member of their teams.
|
||||
ALTER TABLE users ADD COLUMN disabled_at BIGINT;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE alerts ADD COLUMN archived_at INTEGER;
|
||||
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Escalation: page somebody else when the first person does not answer.
|
||||
--
|
||||
-- This is the gap the whole multi-tenancy line of work was opened to close.
|
||||
-- Until now an unacknowledged incident re-paged the same topic every
|
||||
-- notify_repeat forever, which is a louder version of the same silence: if the
|
||||
-- person on call is asleep, has no signal, or has left, nothing else happens.
|
||||
--
|
||||
-- Shape: one policy per team, an ordered list of levels, each level with a
|
||||
-- timeout and a set of targets. When a level's timeout passes and the incident
|
||||
-- is still triggered, the next level is paged. When the last level passes, the
|
||||
-- chain repeats repeat_count times, and then the team's fallback topic is paged
|
||||
-- once as the end of the line.
|
||||
--
|
||||
-- A team WITHOUT a policy keeps exactly today's behaviour: page the assignee,
|
||||
-- then remind on the same topic. Escalation is opt-in per team, and the two
|
||||
-- never both run for one incident -- see enqueueReminders.
|
||||
CREATE TABLE escalation_policies (
|
||||
-- One per team for now, hence the team as the key rather than an id with a
|
||||
-- unique index: routing different alerts to different chains needs the
|
||||
-- alert to carry something to route ON, which is a separate question.
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- How many extra times to run the whole chain after it has been walked
|
||||
-- once. 0 means walk it once and stop at the fallback.
|
||||
repeat_count BIGINT NOT NULL DEFAULT 0 CHECK (repeat_count >= 0 AND repeat_count <= 10),
|
||||
|
||||
-- Where the last page goes when every level has been tried. Per team now:
|
||||
-- TERDUT_NTFY_FALLBACK_TOPIC was one topic for the whole install, which in
|
||||
-- a multi-team server pages the wrong people. Empty means the chain simply
|
||||
-- ends.
|
||||
fallback_topic TEXT NOT NULL DEFAULT '',
|
||||
|
||||
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
);
|
||||
|
||||
CREATE TABLE escalation_levels (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES escalation_policies(team_id) ON DELETE CASCADE,
|
||||
-- 1-based, dense. The API rewrites the whole ladder on every edit rather
|
||||
-- than patching one rung, so there is no way to leave a gap.
|
||||
position BIGINT NOT NULL,
|
||||
-- How long this level has to produce an acknowledgement before the next one
|
||||
-- is paged. Seconds, like every other duration in this schema.
|
||||
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||
|
||||
UNIQUE (team_id, position)
|
||||
);
|
||||
|
||||
-- Who a level pages. Either a named person, or whoever the team's rota says is
|
||||
-- on call today -- which is the target that keeps working when the rota
|
||||
-- changes and nobody remembers to edit the policy.
|
||||
CREATE TABLE escalation_targets (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
level_id BIGINT NOT NULL REFERENCES escalation_levels(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('user', 'oncall')),
|
||||
-- Set for kind='user', NULL for kind='oncall'.
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
CHECK ((kind = 'user' AND user_id IS NOT NULL) OR (kind = 'oncall' AND user_id IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX escalation_targets_level_idx ON escalation_targets(level_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Where an incident is in its chain.
|
||||
--
|
||||
-- On the incident rather than in a side table: it is read on every notifier
|
||||
-- tick alongside the incident's status, and one row per incident is exactly
|
||||
-- what the state is.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 0 means no level has been paged yet, which is the state of every incident
|
||||
-- that existed before escalation and of every incident in a team with no
|
||||
-- policy. 1 is the first level.
|
||||
ALTER TABLE incidents ADD COLUMN escalation_level BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- When the current level was entered, and therefore what its timeout is
|
||||
-- measured from. NULL while escalation_level is 0.
|
||||
ALTER TABLE incidents ADD COLUMN escalation_level_at BIGINT;
|
||||
|
||||
-- How many times the chain has been walked in full. Compared against the
|
||||
-- policy's repeat_count.
|
||||
ALTER TABLE incidents ADD COLUMN escalation_round BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- The notifier's escalation query: incidents still waiting, oldest level first.
|
||||
CREATE INDEX incidents_escalation_idx
|
||||
ON incidents(escalation_level_at)
|
||||
WHERE resolved_at IS NULL AND status = 'triggered';
|
||||
|
||||
-- 'escalated' joins the outbox kinds: a page that went out because nobody
|
||||
-- answered the last one, which is worth telling apart from the first page and
|
||||
-- from a reminder when reading the timeline or debugging a delivery.
|
||||
ALTER TABLE notifications DROP CONSTRAINT notifications_kind_check;
|
||||
ALTER TABLE notifications ADD CONSTRAINT notifications_kind_check
|
||||
CHECK (kind IN ('triggered', 'reminder', 'resolved', 'escalated'));
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Records why an alert left the firing state: 'alertmanager' when a resolved
|
||||
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
|
||||
-- NULL for firing alerts and for rows that predate this migration.
|
||||
ALTER TABLE alerts ADD COLUMN resolution_source TEXT;
|
||||
@@ -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;
|
||||
@@ -8,6 +8,12 @@ import "time"
|
||||
// Incident an alert belongs to.
|
||||
type Alert struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
// TeamID is the team whose integration received this alert, and TeamName
|
||||
// rides along so a combined list can label a row without a second request.
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name,omitempty"`
|
||||
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "firing" or "resolved"
|
||||
|
||||
@@ -11,6 +11,12 @@ import "time"
|
||||
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
||||
// stopped firing.
|
||||
type Incident struct {
|
||||
// TeamID is the team that owns this incident, fixed when it opens: an
|
||||
// incident never moves between teams. TeamName rides along so the combined
|
||||
// queue can badge each row without a second request.
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name,omitempty"`
|
||||
|
||||
ID int64 `json:"id"`
|
||||
GroupKey string `json:"group_key"`
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -4,6 +4,13 @@ import "time"
|
||||
|
||||
type ScheduleEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
// TeamID is whose rota this shift belongs to; TeamName rides along so the
|
||||
// combined "who is on call" view can label each entry without a second
|
||||
// request.
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name,omitempty"`
|
||||
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Team is the unit of tenancy: it owns its incidents, alerts, schedule and
|
||||
// integrations, and a user sees exactly the teams they belong to.
|
||||
type Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Role is the caller's own role in this team, populated when a team is
|
||||
// listed for a particular person. Empty when nobody in particular is
|
||||
// asking, as in the admin listing.
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// Team roles. An owner configures the team — its schedule, its integrations and
|
||||
// who is in it. A member works its incidents.
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleMember = "member"
|
||||
)
|
||||
|
||||
// TeamMember is one person's membership of one team.
|
||||
type TeamMember struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
// Integration is how alerts get in, and the only thing that says which team an
|
||||
// arriving alert belongs to.
|
||||
type Integration struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
|
||||
// Key is the raw integration key, shown once when the integration is
|
||||
// created and never stored. URL is the address to point the sender at,
|
||||
// likewise only complete at creation time.
|
||||
Key string `json:"key,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Integration kinds.
|
||||
const (
|
||||
IntegrationAlertmanager = "alertmanager"
|
||||
)
|
||||
@@ -12,6 +12,17 @@ type User struct {
|
||||
// none of their own; incidents assigned to them fall back to the configured
|
||||
// fallback topic instead.
|
||||
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
||||
|
||||
// DisabledAt is when the account was taken out of use, or nil. A disabled
|
||||
// user cannot authenticate by either credential, and keeps their name on
|
||||
// every acknowledgement and timeline entry they made.
|
||||
DisabledAt *time.Time `json:"disabled_at,omitempty"`
|
||||
|
||||
// IsAdmin is the system administrator flag: managing users and API keys.
|
||||
// Not omitempty — a client has to be able to tell "false" from "this server
|
||||
// is too old to have the field", and the web UI decides what to show from
|
||||
// it.
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
/* 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); }
|
||||
/* Which team's queue a row came from. Only rendered for somebody in more than
|
||||
one team, so it never repeats the same word down the whole list. */
|
||||
/* Separates the status chips from the team chips in the queue's filter row. */
|
||||
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
|
||||
|
||||
.row-team {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
.row.resolved .row-title { color: var(--muted); }
|
||||
|
||||
.sev-critical { --sev: var(--crit); }
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* --- admin ---------------------------------------------------------------
|
||||
The admin page is three tables of things you act on, so it needs table
|
||||
styling the rest of the app never did: the queue is a list of links and the
|
||||
account page is a form. */
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.admin-table th {
|
||||
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
|
||||
/* A disabled account stays readable — it is still the name on old
|
||||
acknowledgements — but should not look like a working one. */
|
||||
.disabled-row td { opacity: 0.55; }
|
||||
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
||||
|
||||
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.inline-form input { flex: 1; min-width: 0; }
|
||||
|
||||
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
|
||||
.admin-settings .setting-unit { max-width: 8em; }
|
||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||
.small { font-size: 13px; }
|
||||
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 |
@@ -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 |
@@ -0,0 +1,97 @@
|
||||
<!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>
|
||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||
unhides it once /api/me says so. The server refuses every admin
|
||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||
<span class="nav-label">Admin</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/more" data-section="more">
|
||||
<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-admin" class="view view-page" data-view="admin" 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>
|
||||
@@ -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 })))));
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Administration: the teams on this server, the people who can sign in, and
|
||||
// the settings that change how the server behaves.
|
||||
//
|
||||
// Only rendered for a system administrator. The server enforces that on every
|
||||
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
||||
// permission — so this view simply says so rather than pretending to be a
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
|
||||
let data = null; // { teams, users, settings }
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
export function show() {
|
||||
if (!data) clear(view(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (!state.me?.user?.is_admin) {
|
||||
data = null;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [teams, users, settings] = await Promise.all([
|
||||
api.adminTeams(),
|
||||
api.users(),
|
||||
api.adminSettings(),
|
||||
]);
|
||||
data = { teams, users, settings };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(view(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
clear(view(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
teamsCard(),
|
||||
usersCard(),
|
||||
settingsCard(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- teams -----------------------------------------------------------------
|
||||
|
||||
function teamsCard() {
|
||||
const rows = data.teams.map((t) =>
|
||||
h('tr', {},
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
h('td', { class: 'num', text: String(t.members) }),
|
||||
h('td', { class: 'num', text: String(t.open_incidents) }),
|
||||
h('td', {},
|
||||
h('button', {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: 'Rename',
|
||||
onclick: () => renameTeam(t),
|
||||
}),
|
||||
// A team with open incidents cannot be deleted, and saying so before
|
||||
// the click is kinder than a 409 afterwards.
|
||||
h('button', {
|
||||
class: 'btn-sm danger',
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
disabled: t.open_incidents > 0,
|
||||
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
|
||||
onclick: () => deleteTeam(t),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Name' }),
|
||||
h('th', { class: 'num', text: 'Members' }),
|
||||
h('th', { class: 'num', text: 'Open' }),
|
||||
h('th', { text: '' }))),
|
||||
h('tbody', {}, rows)),
|
||||
newTeamForm(),
|
||||
);
|
||||
}
|
||||
|
||||
function newTeamForm() {
|
||||
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
|
||||
const form = h('form', { class: 'inline-form' }, name,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.createTeam(name.value.trim());
|
||||
name.value = '';
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
async function renameTeam(team) {
|
||||
const next = window.prompt(`Rename ${team.name} to:`, team.name);
|
||||
if (!next || next === team.name) return;
|
||||
try {
|
||||
await api.renameTeam(team.id, next);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function deleteTeam(team) {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${team.name}?`,
|
||||
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
|
||||
function usersCard() {
|
||||
const rows = data.users.map((u) => {
|
||||
const self = u.id === myID();
|
||||
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||
h('td', {},
|
||||
h('strong', { text: u.username }),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('td', { class: 'muted', text: u.email }),
|
||||
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
|
||||
h('td', {},
|
||||
// Neither action is offered for your own account: the server refuses
|
||||
// both, and an enabled-looking button that always fails is worse than
|
||||
// no button.
|
||||
!self && h('button', {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
onclick: () => setAdmin(u, !u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
class: 'btn-sm danger',
|
||||
type: 'button',
|
||||
text: u.disabled_at ? 'Enable' : 'Disable',
|
||||
onclick: () => setDisabled(u, !u.disabled_at),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Users' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||
'its acknowledgements and timeline entries. Deleting a user erases those.'),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'User' }),
|
||||
h('th', { text: 'Email' }),
|
||||
h('th', { text: '' }),
|
||||
h('th', { text: '' }))),
|
||||
h('tbody', {}, rows)),
|
||||
);
|
||||
}
|
||||
|
||||
async function setAdmin(user, next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Make ${user.username} an administrator?`,
|
||||
text: 'They will be able to create and delete users, and grant this to others.',
|
||||
confirmLabel: 'Make admin',
|
||||
}))) return;
|
||||
try {
|
||||
await api.setUserAdmin(user.id, next);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function setDisabled(user, next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Disable ${user.username}?`,
|
||||
text: 'They cannot sign in and their API keys stop working. Their history stays.',
|
||||
confirmLabel: 'Disable',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.setUserDisabled(user.id, next);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// --- settings --------------------------------------------------------------
|
||||
|
||||
// Seconds are what the API speaks; people think in minutes and hours. The two
|
||||
// are converted here rather than in the server, which should keep exactly one
|
||||
// unit.
|
||||
const UNITS = [
|
||||
{ label: 'minutes', seconds: 60 },
|
||||
{ label: 'hours', seconds: 3600 },
|
||||
{ label: 'days', seconds: 86400 },
|
||||
];
|
||||
|
||||
function bestUnit(seconds) {
|
||||
for (const u of [...UNITS].reverse()) {
|
||||
if (seconds > 0 && seconds % u.seconds === 0) return u;
|
||||
}
|
||||
return UNITS[0];
|
||||
}
|
||||
|
||||
function settingsCard() {
|
||||
const editable = data.settings.editable || {};
|
||||
const inputs = new Map();
|
||||
|
||||
const rows = Object.entries(editable).map(([key, s]) => {
|
||||
const unit = bestUnit(s.seconds);
|
||||
const value = h('input', {
|
||||
type: 'number',
|
||||
min: '0',
|
||||
value: String(Math.round(s.seconds / unit.seconds)),
|
||||
class: 'setting-value',
|
||||
});
|
||||
const select = h('select', { class: 'setting-unit' },
|
||||
...UNITS.map((u) => h('option', {
|
||||
value: String(u.seconds),
|
||||
text: u.label,
|
||||
selected: u.seconds === unit.seconds,
|
||||
})));
|
||||
inputs.set(key, () => Number(value.value) * Number(select.value));
|
||||
|
||||
return h('tr', {},
|
||||
h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })),
|
||||
h('td', { class: 'muted small', text: s.description }),
|
||||
h('td', {}, value, select),
|
||||
);
|
||||
});
|
||||
|
||||
const form = h('form', { class: 'admin-settings' },
|
||||
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Save settings' }));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
const body = {};
|
||||
for (const [key, read] of inputs) body[key] = read();
|
||||
try {
|
||||
await api.setAdminSettings(body);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
|
||||
const env = Object.entries(data.settings.from_env || {}).map(([k, v]) =>
|
||||
h('tr', {},
|
||||
h('td', {}, h('code', { text: k })),
|
||||
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Settings' }),
|
||||
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
|
||||
form,
|
||||
h('h3', { text: 'From the environment' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Where the server is plugged in, rather than how it behaves. These are set ',
|
||||
'in the deployment and are read-only here. Credentials are never shown.'),
|
||||
h('table', { class: 'admin-table' }, h('tbody', {}, env)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 teams = () => call('GET', '/teams');
|
||||
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
||||
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
||||
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
|
||||
|
||||
// Administration. Every one of these is refused with 403 for anybody without
|
||||
// the flag, so the UI hides the section rather than guarding it.
|
||||
export const adminTeams = () => call('GET', '/admin/teams');
|
||||
export const adminSettings = () => call('GET', '/admin/settings');
|
||||
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
||||
export const setUserAdmin = (id, isAdmin) =>
|
||||
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
||||
export const setUserDisabled = (id, disabled) =>
|
||||
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
||||
export const schedule = (teamID, from, to) =>
|
||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||
|
||||
// One entry per team the viewer belongs to, for the teams that have somebody
|
||||
// scheduled today. An empty array means nobody anywhere, which is a real answer
|
||||
// rather than an error — unlike the pre-teams endpoint, which 404ed.
|
||||
export const onCallNow = () => call('GET', '/schedule/current');
|
||||
@@ -0,0 +1,240 @@
|
||||
// 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, loadTeams } from './state.js';
|
||||
import * as queue from './queue.js';
|
||||
import * as incident from './incident.js';
|
||||
import * as oncall from './oncall.js';
|
||||
import * as alerts from './alerts.js';
|
||||
import * as account from './account.js';
|
||||
import * as admin from './admin.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 },
|
||||
admin: { title: 'Admin', view: admin },
|
||||
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 === 'admin' || 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();
|
||||
await loadTeams();
|
||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
showApp();
|
||||
} catch (err) {
|
||||
if (err.status === 401) showLogin();
|
||||
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();
|
||||
@@ -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) || '?';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||
// Read-only for now; the TUI edits the schedule.
|
||||
//
|
||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||
// team has nothing to choose between. "On call now" is the exception and shows
|
||||
// every team the viewer is in, because somebody on two rotas wants both.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
|
||||
import { myID, currentTeam } 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 team = currentTeam();
|
||||
if (!team) {
|
||||
data = { now: [], week: [], upcoming: [] };
|
||||
error = null;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const [now, week, upcoming] = await Promise.all([
|
||||
api.onCallNow(),
|
||||
api.schedule(team.id, isoDate(start), isoDate(addDays(start, 6))),
|
||||
api.schedule(team.id, isoDate(today), isoDate(addDays(today, 60))),
|
||||
]);
|
||||
if (start !== weekStart) return;
|
||||
data = { now, week, upcoming };
|
||||
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;
|
||||
}
|
||||
|
||||
// One card per team with somebody on call, and a single empty card when there
|
||||
// is nobody anywhere. The team's name is shown only when the viewer is in more
|
||||
// than one, so the common case reads exactly as it did before teams existed.
|
||||
function nowCard() {
|
||||
const entries = data.now || [];
|
||||
const showTeam = entries.length > 1;
|
||||
if (entries.length === 0) {
|
||||
return h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar none', text: '–' }),
|
||||
h('div', {},
|
||||
h('div', { class: 'now-label', text: 'On call now' }),
|
||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
||||
),
|
||||
);
|
||||
}
|
||||
return h('div', {}, ...entries.map((n) =>
|
||||
h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', {
|
||||
class: 'now-label',
|
||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
||||
}),
|
||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
function weekCard() {
|
||||
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`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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 teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||
let items = null; // null while loading
|
||||
let error = null;
|
||||
let selected = null;
|
||||
let cursor = -1; // keyboard position in the list
|
||||
let built = false;
|
||||
|
||||
function loadTeamFilter() {
|
||||
try {
|
||||
return sessionStorage.getItem('terdut.queue.team') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setTeamFilter(id) {
|
||||
teamFilter = id;
|
||||
try {
|
||||
sessionStorage.setItem('terdut.queue.team', id);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
renderChips();
|
||||
refresh({ fresh: true });
|
||||
}
|
||||
|
||||
function loadFilter() {
|
||||
try {
|
||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
||||
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.
|
||||
// The cached open queue covers every team, so it can only be reused when
|
||||
// no team filter is applied.
|
||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||
const result = cached ? state.open : await api.incidents(query);
|
||||
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');
|
||||
const chips = FILTERS.map((f) =>
|
||||
h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(f.id === filter),
|
||||
onclick: () => setFilter(f.id),
|
||||
text: f.label,
|
||||
}),
|
||||
);
|
||||
|
||||
// Somebody in one team has nothing to choose between, so the row of team
|
||||
// chips appears only when there is more than one. The default is all of
|
||||
// them: the combined queue is the point.
|
||||
if (state.teams.length > 1) {
|
||||
chips.push(h('span', { class: 'chip-sep' }));
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === ''),
|
||||
onclick: () => setTeamFilter(''),
|
||||
text: 'All teams',
|
||||
}));
|
||||
for (const team of state.teams) {
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === String(team.id)),
|
||||
onclick: () => setTeamFilter(String(team.id)),
|
||||
text: team.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
clear(el, chips);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('queue-list');
|
||||
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}`))));
|
||||
// The team is shown only to somebody who is in more than one. For everybody
|
||||
// else it is the same word on every row, which is noise rather than
|
||||
// information.
|
||||
const team = state.teams.length > 1 && inc.team_name
|
||||
? h('span', { class: 'row-team', text: inc.team_name })
|
||||
: null;
|
||||
|
||||
return h('a', {
|
||||
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
|
||||
href: `/incidents/${inc.id}`,
|
||||
'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,
|
||||
team,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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
|
||||
teams: [], // the teams the viewer belongs to, each with their role
|
||||
};
|
||||
|
||||
// The team whose schedule and settings the views act on. A viewer in one team —
|
||||
// which is everybody until somebody makes a second — never has to choose.
|
||||
export function currentTeam() {
|
||||
return state.teams[0] || null;
|
||||
}
|
||||
|
||||
export function myID() {
|
||||
return state.me ? state.me.user.id : null;
|
||||
}
|
||||
|
||||
// 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 async function loadTeams() {
|
||||
state.teams = await api.teams();
|
||||
return state.teams;
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
state.me = null;
|
||||
state.open = [];
|
||||
state.teams = [];
|
||||
usersCache = null;
|
||||
}
|
||||
@@ -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' }));
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user