Compare commits

..

11 Commits

Author SHA1 Message Date
Niklas Ye cd3b2c2bea Clamp the avatar scaler's channels before narrowing them
CI / chart (push) Successful in 2s
CI / security (push) Successful in 33s
CI / test (push) Successful in 6m36s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m31s
Release / scan-image (push) Successful in 32s
Release / binaries (push) Successful in 3m16s
gosec's G115 failed the security job on v0.50.0's first push: the box
filter narrows uint64 channel sums to uint8. They cannot overflow, since a
premultiplied channel never exceeds its alpha, but the conversion now goes
through to8, which clamps to 16 bits first so that holds by construction
rather than by argument.

No behaviour change, and nothing else in the avatar work moves.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:46:35 +01:00
Niklas Ye 525947e809 Set the chart's placeholder version to 0.50.0
CI / chart (push) Successful in 2s
CI / security (push) Failing after 3m33s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.50.0 does not say 0.49.1, as in
the 0.49.1 placeholder commit and 12535e0.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:42:05 +01:00
Niklas Ye 536dbcaebe Let users upload a profile picture, cropped in the browser
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.

The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.

GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:42:05 +01:00
Niklas Ye 50e9ebcbc2 Set the chart's placeholder version to 0.49.1
CI / chart (push) Successful in 1s
CI / test (push) Successful in 24s
CI / security (push) Successful in 34s
Release / test (push) Successful in 6s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 30s
Release / image (push) Successful in 1m12s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.49.1 does not say 0.49.0, as in
the 0.49.0 placeholder commit and 12535e0.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 17:16:19 +02:00
Niklas Ye 83837421e7 Default the allowed Prometheus host to kube-prometheus-stack's
v0.49.0 shipped the metrics feature with an empty allowlist, so nothing
showed on an incident until someone set prometheus.allowedHosts. The chart
now defaults it to kube-prometheus-stack-prometheus.kube-prometheus-stack:9090,
the Prometheus that stack installs. An empty list still turns the feature
off.

Chart default only: the server's own default (TERDUT_PROMETHEUS_ALLOWED_HOSTS
empty) is unchanged, and no Go code, API or schema changed.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 17:16:19 +02:00
Niklas Ye c242058f6c Set the chart's placeholder version to 0.49.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 44s
CI / test (push) Successful in 6m14s
Release / test (push) Successful in 6s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 54s
Release / image (push) Successful in 1m36s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.49.0 does not say 0.48.0, as in
12535e0 and 220eb97.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 14:10:38 +02:00
Niklas Ye f344bb707b Graph the metric behind each alert on the incident page
Each alert carries a generatorURL pointing at the Prometheus rule that fired
it. The server now parses that link, queries the same Prometheus for the
rule's expression and draws it under the alert, with the current value
beside it. The browser never talks to Prometheus, so the CSP stays at
connect-src 'self'.

The URL comes out of a webhook payload, so whoever holds an integration key
chooses it. Querying it is therefore opt-in and bounded by an allowlist,
TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as
several, IPs, CIDRs). The check runs on the address the dialer is about to
use, a name that resolves to loopback or link-local is refused even when it
matches, and redirects and proxy variables are ignored.

A worker takes a snapshot when an incident opens and another when it
closes, so the graph outlives Prometheus' retention. It finds its work in
the database rather than being triggered from the webhook, so ingestion is
untouched and a restart loses nothing. While an incident is open the
metrics endpoint also queries live, cached for 15 seconds.

The rule expression keeps its threshold, so Prometheus returns data only
while the alert condition holds; "no data" for the current value means the
condition cleared. Stripping the threshold needs a PromQL parser and is left
out.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 14:10:34 +02:00
Niklas Ye 94c394e8f1 Build and scan with Go 1.26.10
CI / chart (push) Successful in 0s
CI / security (push) Successful in 2m41s
CI / test (push) Successful in 7m7s
Release / test (push) Successful in 10s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m37s
Release / scan-image (push) Successful in 8s
Release / binaries (push) Successful in 2m51s
govulncheck started failing CI on GO-2026-6809, a crypto/x509 flaw in the
standard library that is fixed in 1.26.10 and reachable through
http.Server.ListenAndServe. golang:1.26.9 is what ci.yaml, release.yaml and
the Dockerfile build with, so all five pins move together: the scan and the
shipped binary must agree on the toolchain, as in 3173abf.

No code change; go.mod still says go 1.25.9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:55:04 +02:00
Niklas Ye 12535e0e9a Set the chart's placeholder version to 0.48.0
CI / chart (push) Successful in 1s
CI / security (push) Failing after 2m4s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.48.0 does not say 0.47.1, as in
220eb97 and fcd8f83.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:47:50 +02:00
Niklas Ye 178c723475 Break the flat background with a fading dot grid
The web UI sat on one flat colour. A dot grid on body::before, solid for the
top quarter of the screen and faded out by 88%, gives the page some texture
without adding contrast behind text.

It is a pseudo-element at z-index -1 rather than a wrapper div, so index.html
and the JS are untouched: the layer sits above the canvas background and below
all in-flow content. Cards, rows, the sidebar and sheets keep their solid
surfaces; the top and bottom bars are translucent and let it show faintly.
The sticky filter strip over the queue is opaque and hides the dots behind it.

--dot is a new colour token, light on dark and dark on light, declared in all
three palette blocks (the two dark ones stay identical, as the file requires).

No API, schema or CSP change, and no change to terdut-tui.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:47:45 +02:00
Niklas Ye 8b9adb4910 Let the instance service account list a team's deadman switches
CI / chart (push) Successful in 1s
CI / security (push) Successful in 34s
CI / test (push) Successful in 6m26s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / scan-image (push) Successful in 6s
Release / test (push) Successful in 12s
Release / image (push) Successful in 1m8s
GET /api/teams/{id}/deadman/switches used requireTeamMember, which 404s
for the instance-scoped service account because it belongs to no team.
terdut-operator lists switches by name on every reconcile, so TerdutTeams
never became ready. Instance service accounts now pass, as they already do
for the write endpoints.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-20 15:34:31 +02:00
39 changed files with 2575 additions and 27 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
# Runs inside the toolchain image rather than installing Go per job. Note this puts
# the job on the dind bridge, which cannot reach github.com or get.helm.sh --
# proxy.golang.org and git.ryuvia.com are reachable, which is all this job needs.
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
# act_runner destroys a job's own volumes when it finishes, so without these every
# run re-downloads the whole module graph. The names must appear in the runner's
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
@@ -101,7 +101,7 @@ jobs:
security:
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
@@ -71,7 +71,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
+1 -1
View File
@@ -4,7 +4,7 @@
# in per platform. The CI runner has no binfmt registration and no way to get one (the
# JS action that used to install it cannot run there), so this is not just an
# optimisation -- it is what makes the arm64 image buildable at all.
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.10-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+1
View File
@@ -92,6 +92,7 @@ The [documentation index](./docs/README.md) lists everything. The main pages:
|---|---|
| [Deployment](./docs/deployment.md) | Docker, Helm chart, database, backups, the operator |
| [Configuration](./docs/configuration.md) | Environment variables and settings |
| [Metrics](./docs/metrics.md) | Graphs from the alert's Prometheus on the incident page |
| [Alertmanager configuration](./docs/alertmanager.md) | Routes, keys, webhooks |
| [Alerts and incidents](./docs/incidents.md) | Correlation, lifecycle, on-call assignment |
| [Single sign-on](./docs/single-sign-on.md) | OIDC and the terminal device flow |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.47.1
appVersion: "v0.47.1"
version: 0.50.0
appVersion: "v0.50.0"
@@ -120,6 +120,14 @@ spec:
value: {{ .Values.trustedProxies | quote }}
- name: TERDUT_OPERATOR_MODE
value: {{ .Values.operatorMode | quote }}
{{- if .Values.prometheus.allowedHosts }}
- name: TERDUT_PROMETHEUS_ALLOWED_HOSTS
value: {{ join "," .Values.prometheus.allowedHosts | quote }}
- name: TERDUT_PROMETHEUS_PREROLL
value: {{ .Values.prometheus.preroll | quote }}
- name: TERDUT_PROMETHEUS_TIMEOUT
value: {{ .Values.prometheus.timeout | quote }}
{{- end }}
{{- if .Values.oidc.enabled }}
- name: TERDUT_OIDC_ISSUER
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
+15
View File
@@ -114,6 +114,21 @@ passwordLogin: true
# configuration from here on.
operatorMode: false
# Metrics on incidents: the server queries the Prometheus an alert came from
# (the host in its generatorURL) and graphs the alert's expression. The URL is
# taken from a webhook payload, so only hosts listed here are ever contacted;
# empty turns the feature off. Entries are host names, with "*" as one DNS label
# and a leading "**" as one or more (e.g. "*.monitoring.svc.cluster.local",
# "prometheus-*.monitoring"), IPs or CIDR ranges, each with an optional ":port".
# The default is the Prometheus that kube-prometheus-stack installs; set it to
# [] to turn the feature off.
prometheus:
allowedHosts:
- kube-prometheus-stack-prometheus.kube-prometheus-stack:9090
# How far before an alert started its graph begins.
preroll: 1h
timeout: 10s
# Single sign-on through an OpenID Connect provider such as Authentik.
#
# At the provider, create an OAuth2/OpenID application whose redirect URI is
+1
View File
@@ -64,6 +64,7 @@ func main() {
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notifyCfg)
go api.StartNotifier(ctx, database, notifyCfg)
go api.StartMetricsWorker(ctx, database, cfg)
go func() {
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
+1
View File
@@ -236,6 +236,7 @@ the team gets the same `404` as anybody else.
| `GET` | `/api/incidents/{id}` | Get single incident, with its alerts inline |
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
| `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological |
| `GET` | `/api/incidents/{id}/metrics` | Metrics behind the incident's alerts, from their Prometheus — see [Metrics](./metrics.md) |
| `POST` | `/api/incidents/{id}/acknowledge` | Acknowledge (stamps authed user + time) |
| `DELETE` | `/api/incidents/{id}/acknowledge` | Clear acknowledgement, back to `triggered` |
| `POST` | `/api/incidents/{id}/resolve` | Close by hand — **terminal**, see above |
+3
View File
@@ -30,6 +30,9 @@ over an administrator's edit.
| `TERDUT_TRUSTED_PROXIES` | `1` | How many reverse proxies in front of the server append to `X-Forwarded-For`; the per-address rate limits use the entry that many hops from the right. `0` ignores the header |
| `TERDUT_OPERATOR_KEY` | — | At least 32 characters. When set, the instance-scoped service account `terdut-operator` is created if missing and its `seed` key replaced with this value at every start — how terdut-operator authenticates without a bootstrap handshake. An instance-scoped account acts as owner of every team (team configuration) but is not a member of any, so it reads no incidents |
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](./api.md#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — (the Helm chart defaults to `kube-prometheus-stack-prometheus.kube-prometheus-stack:9090`) | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
| `TERDUT_PROMETHEUS_PREROLL` | `1h` | How far before an alert started its metrics graph begins |
| `TERDUT_PROMETHEUS_TIMEOUT` | `10s` | Per-request timeout for a Prometheus query |
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](./single-sign-on.md#single-sign-on-oidc) |
| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values |
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
+75
View File
@@ -0,0 +1,75 @@
# Metrics on incidents
Alertmanager sends each alert with a `generatorURL`: the link to the rule's graph in
the Prometheus that fired it. With the feature on, terdut-server parses that link,
queries the same Prometheus for the rule's expression, and shows a graph and the
current value under each alert on the incident page. The browser never talks to
Prometheus; the server does.
## Turning it on
Set `TERDUT_PROMETHEUS_ALLOWED_HOSTS` (Helm: `prometheus.allowedHosts`). The URL arrives in a
webhook payload, so whoever holds an integration key chooses it; without an allowlist the
server would fetch whatever it was told to. Empty, the default, means off.
| Entry | Matches |
|---|---|
| `prometheus.monitoring.svc` | that host, any port |
| `prometheus.monitoring.svc:9090` | that host and port |
| `*.example.com` | `a.example.com`; not `example.com`, not `a.b.example.com` |
| `prom-*.monitoring.svc` | `prom-0.monitoring.svc` |
| `**.example.com` | `a.example.com` and `a.b.example.com` |
| `10.0.0.0/8`, `[fd00::]/8:9090` | addresses in the range |
`*` stands for one DNS label (or part of one); `**` is allowed only as the first label and means
one or more. Matching ignores case. A bare `*` or `**`, or any entry whose last label is only a
wildcard, is refused at startup.
The check is made on the address the connection is about to use, not just on the name, so a
name that resolves somewhere else (DNS rebinding) is caught. A host admitted by name is still
refused if it resolves to a loopback, link-local (which includes the cloud metadata address),
unspecified or multicast address; to allow one deliberately, list it as an IP or CIDR. Redirects
and proxy environment variables are ignored.
## What is captured
- **Snapshots.** A background worker stores each alert's series when the incident opens
(`opened`) and when it closes (`closed`), in Postgres, so the graph outlives Prometheus'
own retention. Incidents closed more than 24 hours before the feature was switched on are not
backfilled. A failed capture is retried a few times; a host that is not allowed, or a link that is
not a Prometheus graph link, is recorded as failed once.
- **Live data.** While an incident is open, `GET /api/incidents/{id}/metrics` also queries
Prometheus (answers are reused for 15 seconds) and returns the current value.
- **Window.** From `TERDUT_PROMETHEUS_PREROLL` (default 1h) before the alert started until now,
or until the incident was resolved.
## Reading the graph
The expression is the alert rule's own, threshold included (`latency > 1`), so Prometheus only
returns data while the condition is true. The graph therefore shows the firing period, and **"no
data" for the current value means the condition no longer holds**, not that Prometheus is down.
A rule expression matches every series, not just the one that fired; only series that agree with the
alert's labels are kept, at most five per alert.
## Endpoint
`GET /api/incidents/{id}/metrics` — same team scoping as the other incident routes.
```json
{
"enabled": true,
"alerts": [{
"alert_id": 12, "alert_name": "HighLatency", "expr": "latency>1",
"available": true,
"snapshots": { "opened": { "window_start": 1, "window_end": 2, "step_seconds": 15,
"series": [{"labels": {"instance": "web-1"}, "points": [[100, 1.5]]}],
"captured_at": 3 } },
"live": { "window_start": 1, "window_end": 2, "step_seconds": 15,
"series": [], "current": [] }
}]
}
```
`enabled` is `false` when no host is allowed. `available` is `false`, with a `reason`, when this
alert has no Prometheus link or its host is not allowed. A snapshot that failed carries `error`.
`live` is absent once the incident is resolved. Points are `[unix seconds, value]`.
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"bytes"
"database/sql"
"errors"
"image"
"image/color"
"image/draw"
_ "image/jpeg" // registers the JPEG decoder with image.Decode
"image/png"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
// avatarSize is the side of the stored square picture. The web UI crops to
// it before uploading; the server enforces it anyway.
avatarSize = 256
// maxAvatarBytes caps the upload. The web UI sends a ~256x256 PNG, a few
// tens of KiB; this leaves room for a client that sends a photo as is.
maxAvatarBytes = 1 << 20
// maxAvatarPixels bounds the decoded size, checked from the header before
// decoding: a small file can declare an enormous canvas.
maxAvatarPixels = 16 << 20
)
// normalizeAvatar decodes a PNG or JPEG, crops it to its centred square, scales
// it to avatarSize and encodes it as PNG. Decoding and re-encoding is what
// makes the stored bytes trustworthy: EXIF, trailing data and anything that is
// not pixels do not survive it.
func normalizeAvatar(raw []byte) ([]byte, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxAvatarPixels {
return nil, errors.New("the picture is too large")
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
b := img.Bounds()
side := min(b.Dx(), b.Dy())
src := image.Rect(
b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2,
b.Min.X+(b.Dx()-side)/2+side, b.Min.Y+(b.Dy()-side)/2+side)
var out *image.NRGBA
if side <= avatarSize {
out = image.NewNRGBA(image.Rect(0, 0, side, side))
draw.Draw(out, out.Bounds(), img, src.Min, draw.Src)
} else {
out = boxScale(img, src, avatarSize)
}
var buf bytes.Buffer
if err := png.Encode(&buf, out); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// boxScale shrinks the square region src of img to size x size by averaging the
// source pixels each output pixel covers. Plain area averaging is enough for a
// downscale and keeps the standard library the only dependency.
func boxScale(img image.Image, src image.Rectangle, size int) *image.NRGBA {
out := image.NewNRGBA(image.Rect(0, 0, size, size))
n := src.Dx()
for y := 0; y < size; y++ {
y0, y1 := src.Min.Y+y*n/size, src.Min.Y+(y+1)*n/size
for x := 0; x < size; x++ {
x0, x1 := src.Min.X+x*n/size, src.Min.X+(x+1)*n/size
var r, g, b, a, cnt uint64
for sy := y0; sy < y1; sy++ {
for sx := x0; sx < x1; sx++ {
pr, pg, pb, pa := img.At(sx, sy).RGBA()
r, g, b, a, cnt = r+uint64(pr), g+uint64(pg), b+uint64(pb), a+uint64(pa), cnt+1
}
}
if cnt == 0 {
continue
}
// RGBA() is alpha-premultiplied 16-bit; undo that for NRGBA.
a /= cnt
if a == 0 {
continue
}
r, g, b = r/cnt*0xffff/a, g/cnt*0xffff/a, b/cnt*0xffff/a
out.SetNRGBA(x, y, color.NRGBA{to8(r), to8(g), to8(b), to8(a)})
}
}
return out
}
// to8 narrows a 16-bit channel to 8 bits. The clamp is what makes the
// conversion safe by construction: a premultiplied channel never exceeds its
// alpha, so it is not expected to bite, but nothing here relies on that.
func to8(v uint64) uint8 {
return uint8(min(v, 0xffff) >> 8)
}
func handleGetAvatar(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 data []byte
var version int64
err = db.QueryRowContext(r.Context(),
"SELECT data, updated_at FROM user_avatars WHERE user_id = $1", id).Scan(&data, &version)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("no avatar"))
return
}
if err != nil {
serverError(w, r, err)
return
}
etag := `"` + strconv.FormatInt(version, 10) + `"`
w.Header().Set("ETag", etag)
// private: it is behind a login. The URL carries ?v=, so the browser can
// keep it for a long time without ever showing a stale picture.
w.Header().Set("Cache-Control", "private, max-age=86400")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(data)
}
}
// handleSetAvatar stores the request body, a PNG or JPEG, as the user's picture.
func handleSetAvatar(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
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAvatarBytes))
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
respond(w, http.StatusRequestEntityTooLarge, errResp("the picture is too large"))
return
}
respond(w, http.StatusBadRequest, errResp("could not read the picture"))
return
}
data, err := normalizeAvatar(raw)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
version := time.Now().UnixMilli()
res, err := db.ExecContext(r.Context(),
`INSERT INTO user_avatars (user_id, data, updated_at)
SELECT id, $2, $3 FROM users WHERE id = $1
ON CONFLICT (user_id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
id, data, version)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
respond(w, http.StatusOK, map[string]int64{"avatar_version": version})
}
}
func handleDeleteAvatar(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
}
if _, err := db.ExecContext(r.Context(),
"DELETE FROM user_avatars WHERE user_id = $1", id); err != nil {
serverError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+154
View File
@@ -0,0 +1,154 @@
package api_test
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"net/http"
"testing"
)
func testImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 200, A: 255})
}
}
return img
}
func pngBytes(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// putRaw sends a non-JSON body with the administrator's key.
func (s *ts) putRaw(t *testing.T, path string, body []byte) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodPut, s.URL+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+s.key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestAvatar_UploadNormalizesAndServes(t *testing.T) {
s := newTS(t)
uid, call := member(t, s, "ava")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET before upload: %d, want 404", resp.StatusCode)
}
// A non-square JPEG, bigger than the stored size: cropped and scaled to 256².
var jb bytes.Buffer
if err := jpeg.Encode(&jb, testImage(600, 400), nil); err != nil {
t.Fatal(err)
}
resp := s.putRaw(t, path, jb.Bytes()) // an administrator may set anybody's
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
var out struct {
Version int64 `json:"avatar_version"`
}
decode(t, resp, &out)
resp = call(http.MethodGet, path, nil)
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "image/png" {
t.Fatalf("GET: %d %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
t.Fatalf("served bytes are not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 256 {
t.Errorf("stored size %v, want 256x256", b)
}
var users []struct {
ID int64 `json:"id"`
AvatarVersion *int64 `json:"avatar_version"`
}
decode(t, call(http.MethodGet, "/api/users", nil), &users)
found := false
for _, u := range users {
if u.ID == uid {
found = true
if u.AvatarVersion == nil || *u.AvatarVersion != out.Version {
t.Errorf("user list avatar_version = %v, want %d", u.AvatarVersion, out.Version)
}
}
}
if !found {
t.Fatal("user not listed")
}
if resp := call(http.MethodDelete, path, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE: %d", resp.StatusCode)
}
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET after delete: %d, want 404", resp.StatusCode)
}
}
func TestAvatar_Rejections(t *testing.T) {
s := newTS(t)
_, other := member(t, s, "other")
uid, _ := member(t, s, "ava2")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := other(http.MethodPut, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member PUT: %d, want 403", resp.StatusCode)
}
if resp := other(http.MethodDelete, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member DELETE: %d, want 403", resp.StatusCode)
}
if resp := s.putRaw(t, path, []byte("not an image")); resp.StatusCode != http.StatusBadRequest {
t.Errorf("garbage: %d, want 400", resp.StatusCode)
}
if resp := s.putRaw(t, path, make([]byte, 2<<20)); resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("oversize: %d, want 413", resp.StatusCode)
}
if resp := s.putRaw(t, "/api/users/999999/avatar", pngBytes(t, testImage(32, 32))); resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown user: %d, want 404", resp.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, s.URL+path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unauthenticated GET: %d, want 401", resp.StatusCode)
}
}
func TestAvatar_RemovedWithTheUser(t *testing.T) {
s := newTS(t)
uid, _ := member(t, s, "gone")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := s.putRaw(t, path, pngBytes(t, testImage(64, 64))); resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
if resp := s.req(t, http.MethodDelete, "/api/users/"+id64(uid), nil); resp.StatusCode >= 300 {
t.Fatalf("delete user: %d", resp.StatusCode)
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM user_avatars WHERE user_id = $1", uid).Scan(&n); err != nil || n != 0 {
t.Errorf("avatar rows after user delete = %d (%v), want 0", n, err)
}
}
+10
View File
@@ -1,8 +1,12 @@
package api
import (
"context"
"database/sql"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// DeadmanConfig is a test fixture only: a set of matchers with one timeout and
@@ -29,3 +33,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
}
return cfg
}
// MetricsSweep runs one pass of the metrics worker, so a test need not wait on
// its ticker.
func MetricsSweep(ctx context.Context, db *sql.DB, cfg config.Config) {
newMetricsService(cfg).sweep(ctx, db)
}
+456
View File
@@ -0,0 +1,456 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"sync"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/prom"
)
const (
// metricsInterval is how often the worker looks for incidents that need a
// snapshot. Short, because the "opened" snapshot is most useful when it is
// taken close to the moment the incident opened.
metricsInterval = 15 * time.Second
// metricsBatch caps one pass, and metricsConcurrency how many Prometheus
// requests run at once within it.
metricsBatch = 50
metricsConcurrency = 4
// metricsMaxAttempts stops a failing capture being retried forever, and
// metricsRetryStep spaces the retries: attempt n waits n*metricsRetryStep.
metricsMaxAttempts = 4
metricsRetryStep = time.Minute
// metricsLookback is how far back the worker goes for incidents that are
// already closed. Without it, switching the feature on would try to
// reconstruct the whole history from a Prometheus that has probably aged it
// out. Incidents that are still open are always eligible.
metricsLookback = 24 * time.Hour
// maxAlertsPerIncident bounds the Prometheus work one incident can cause.
maxAlertsPerIncident = 10
// metricsLiveTTL is how long a live answer is reused, so a room full of
// people watching one incident is one query per alert, not one each.
metricsLiveTTL = 15 * time.Second
// metricsLockKey is the advisory lock a pass holds, distinct from the
// archiver's and the notifier's.
metricsLockKey int64 = 7265_0004
)
// metricsService is the Prometheus side of the incident page: a client that only
// reaches allowed hosts, and a short-lived cache of live answers.
type metricsService struct {
client *prom.Client
preroll time.Duration
mu sync.Mutex
cache map[int64]liveEntry
}
type liveEntry struct {
at time.Time
live *liveMetrics
}
func newMetricsService(cfg config.Config) *metricsService {
// Validate has already refused a bad list at startup; a failure here can only
// be a test building a Config by hand, and no allowlist is the safe reading.
allow, err := prom.ParseAllowlist(cfg.PrometheusAllowedHosts)
if err != nil {
allow = nil
}
preroll := cfg.PrometheusPreroll
if preroll <= 0 {
preroll = time.Hour
}
return &metricsService{
client: prom.NewClient(allow, cfg.PrometheusTimeout),
preroll: preroll,
cache: map[int64]liveEntry{},
}
}
func (m *metricsService) enabled() bool { return m.client.Enabled() }
// StartMetricsWorker captures the opened and closed snapshots until ctx is
// cancelled. With no allowed hosts configured it does nothing.
func StartMetricsWorker(ctx context.Context, db *sql.DB, cfg config.Config) {
m := newMetricsService(cfg)
if !m.enabled() {
log.Print("metrics: disabled (TERDUT_PROMETHEUS_ALLOWED_HOSTS is empty)")
return
}
log.Printf("metrics: capturing from %d allowed Prometheus host entries", len(cfg.PrometheusAllowedHosts))
ticker := time.NewTicker(metricsInterval)
defer ticker.Stop()
sweep := func() {
withAdvisoryLock(ctx, db, metricsLockKey, "metrics", func() {
m.sweep(ctx, db)
})
}
sweep()
for {
select {
case <-ticker.C:
sweep()
case <-ctx.Done():
return
}
}
}
// snapshotJob is one (incident, alert, kind) that still needs a snapshot.
type snapshotJob struct {
incidentID int64
alertID int64
kind string
resolvedAt sql.NullInt64
startsAt int64
labels map[string]string
generatorURL string
}
// sweep finds the snapshots that are missing, or failed and are due a retry, and
// takes them.
func (m *metricsService) sweep(ctx context.Context, db *sql.DB) {
now := time.Now()
rows, err := db.QueryContext(ctx, `
SELECT i.id, a.id, k.kind, i.resolved_at, a.starts_at, a.labels, a.generator_url
FROM incidents i
JOIN incident_alerts ia ON ia.incident_id = i.id
JOIN alerts a ON a.id = ia.alert_id
CROSS JOIN (VALUES ('opened'), ('closed')) AS k(kind)
LEFT JOIN incident_metric_snapshots s
ON s.incident_id = i.id AND s.alert_id = a.id AND s.kind = k.kind
WHERE a.generator_url <> ''
AND i.archived_at IS NULL
AND (i.resolved_at IS NULL OR i.resolved_at >= $1)
AND (k.kind = 'opened' OR i.resolved_at IS NOT NULL)
AND (s.id IS NULL
OR (s.error IS NOT NULL AND s.attempts < $2
AND s.captured_at + s.attempts * $3 <= $4))
ORDER BY i.id, a.id, k.kind
LIMIT $5`,
now.Add(-metricsLookback).Unix(), metricsMaxAttempts, int64(metricsRetryStep/time.Second), now.Unix(), metricsBatch)
if err != nil {
log.Printf("metrics: find work: %v", err)
return
}
var jobs []snapshotJob
for rows.Next() {
var j snapshotJob
var labels []byte
if err := rows.Scan(&j.incidentID, &j.alertID, &j.kind, &j.resolvedAt, &j.startsAt, &labels, &j.generatorURL); err != nil {
log.Printf("metrics: scan: %v", err)
rows.Close()
return
}
_ = json.Unmarshal(labels, &j.labels)
jobs = append(jobs, j)
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("metrics: find work: %v", err)
return
}
sem := make(chan struct{}, metricsConcurrency)
var wg sync.WaitGroup
for _, j := range jobs {
sem <- struct{}{}
wg.Add(1)
go func() {
defer func() { <-sem; wg.Done() }()
m.capture(ctx, db, j)
}()
}
wg.Wait()
}
// window is the span a snapshot covers: from the pre-roll before the alert
// started to the end of the incident, or to now while it is open.
func (m *metricsService) window(startsAt int64, resolvedAt sql.NullInt64, now time.Time) (time.Time, time.Time) {
start := time.Unix(startsAt, 0).Add(-m.preroll)
end := now
if resolvedAt.Valid {
end = time.Unix(resolvedAt.Int64, 0)
}
if !end.After(start) {
end = start.Add(time.Minute)
}
return start, end
}
func (m *metricsService) capture(ctx context.Context, db *sql.DB, j snapshotJob) {
now := time.Now()
start, end := m.window(j.startsAt, j.resolvedAt, now)
step := prom.StepFor(start, end)
series, expr, err := m.fetchRange(ctx, j.generatorURL, j.labels, start, end, step)
attempts := 1
var errText any
if err != nil {
errText = err.Error()
if errors.Is(err, prom.ErrNotAllowed) || errors.Is(err, errNotGraphLink) {
attempts = metricsMaxAttempts // retrying cannot change the answer
}
}
if series == nil {
series = []prom.Series{}
}
body, _ := json.Marshal(series)
_, dbErr := db.ExecContext(ctx, `
INSERT INTO incident_metric_snapshots
(incident_id, alert_id, kind, expr, window_start, window_end, step_seconds, series, error, attempts, captured_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (incident_id, alert_id, kind) DO UPDATE SET
expr = excluded.expr, window_start = excluded.window_start, window_end = excluded.window_end,
step_seconds = excluded.step_seconds, series = excluded.series, error = excluded.error,
attempts = CASE WHEN excluded.error IS NULL THEN 1
ELSE GREATEST(incident_metric_snapshots.attempts + 1, excluded.attempts) END,
captured_at = excluded.captured_at`,
j.incidentID, j.alertID, j.kind, expr, start.Unix(), end.Unix(), int(step/time.Second),
string(body), errText, attempts, now.Unix())
if dbErr != nil {
log.Printf("metrics: store snapshot (incident %d, alert %d, %s): %v", j.incidentID, j.alertID, j.kind, dbErr)
}
}
var errNotGraphLink = errors.New("the alert's generator URL is not a Prometheus graph link")
// fetchRange parses the generator URL and returns the series of the alert's own
// expression over [start, end]. expr is returned even on error where known.
func (m *metricsService) fetchRange(ctx context.Context, generatorURL string, labels map[string]string, start, end time.Time, step time.Duration) ([]prom.Series, string, error) {
base, expr, ok := prom.ParseGeneratorURL(generatorURL)
if !ok {
return nil, "", errNotGraphLink
}
series, err := m.client.QueryRange(ctx, base, expr, start, end, step)
if err != nil {
return nil, expr, err
}
return prom.SelectSeries(series, labels), expr, nil
}
// --- REST ---------------------------------------------------------------
type metricsResponse struct {
// Enabled is false when no Prometheus host is allowed, so a client can hide
// the section rather than show every alert as unavailable.
Enabled bool `json:"enabled"`
Alerts []alertMetrics `json:"alerts"`
}
type alertMetrics struct {
AlertID int64 `json:"alert_id"`
AlertName string `json:"alert_name"`
Expr string `json:"expr,omitempty"`
// Available says whether this alert can be queried at all; Reason says why
// not when it cannot.
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Snapshots map[string]*snapshotView `json:"snapshots"`
Live *liveMetrics `json:"live,omitempty"`
}
type snapshotView struct {
WindowStart int64 `json:"window_start"`
WindowEnd int64 `json:"window_end"`
Step int `json:"step_seconds"`
Series json.RawMessage `json:"series"`
Error string `json:"error,omitempty"`
CapturedAt int64 `json:"captured_at"`
}
type liveMetrics struct {
WindowStart int64 `json:"window_start"`
WindowEnd int64 `json:"window_end"`
Step int `json:"step_seconds"`
Series []prom.Series `json:"series"`
// Current is the expression's value right now, one point per series. Empty
// means the condition that fires the alert no longer holds.
Current []prom.Series `json:"current"`
Error string `json:"error,omitempty"`
}
func handleIncidentMetrics(db *sql.DB, m *metricsService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := incidentIDParam(w, r, db)
if !ok {
return
}
resp := metricsResponse{Enabled: m.enabled(), Alerts: []alertMetrics{}}
if !resp.Enabled {
respond(w, http.StatusOK, resp)
return
}
var resolvedAt sql.NullInt64
if err := db.QueryRowContext(r.Context(), "SELECT resolved_at FROM incidents WHERE id = $1", id).Scan(&resolvedAt); err != nil {
serverError(w, r, err)
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT a.id, a.name, a.labels, a.generator_url, a.starts_at
FROM alerts a
JOIN incident_alerts ia ON ia.alert_id = a.id
WHERE ia.incident_id = $1
ORDER BY a.starts_at, a.id
LIMIT $2`, id, maxAlertsPerIncident)
if err != nil {
serverError(w, r, err)
return
}
type alertRow struct {
am alertMetrics
labels map[string]string
url string
startsAt int64
}
var alerts []*alertRow
for rows.Next() {
var a alertRow
var labels []byte
if err := rows.Scan(&a.am.AlertID, &a.am.AlertName, &labels, &a.url, &a.startsAt); err != nil {
rows.Close()
serverError(w, r, err)
return
}
_ = json.Unmarshal(labels, &a.labels)
a.am.Snapshots = map[string]*snapshotView{}
alerts = append(alerts, &a)
}
rows.Close()
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
snaps, err := loadSnapshots(r.Context(), db, id)
if err != nil {
serverError(w, r, err)
return
}
var wg sync.WaitGroup
for _, a := range alerts {
a := a
if base, expr, ok := prom.ParseGeneratorURL(a.url); !ok {
a.am.Reason = "no Prometheus link on this alert"
} else if !m.client.Allowed(base) {
a.am.Reason = "Prometheus host is not allowed"
a.am.Expr = expr
} else {
a.am.Available = true
a.am.Expr = expr
}
for kind, s := range snaps[a.am.AlertID] {
a.am.Snapshots[kind] = s
}
if a.am.Available && !resolvedAt.Valid {
wg.Add(1)
go func() {
defer wg.Done()
a.am.Live = m.live(r.Context(), a.am.AlertID, a.url, a.labels, a.startsAt)
}()
}
}
wg.Wait()
for _, a := range alerts {
resp.Alerts = append(resp.Alerts, a.am)
}
respond(w, http.StatusOK, resp)
}
}
func loadSnapshots(ctx context.Context, db *sql.DB, incidentID int64) (map[int64]map[string]*snapshotView, error) {
rows, err := db.QueryContext(ctx, `
SELECT alert_id, kind, window_start, window_end, step_seconds, series, COALESCE(error, ''), captured_at
FROM incident_metric_snapshots
WHERE incident_id = $1`, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]map[string]*snapshotView{}
for rows.Next() {
var alertID int64
var kind string
var s snapshotView
var series []byte
if err := rows.Scan(&alertID, &kind, &s.WindowStart, &s.WindowEnd, &s.Step, &series, &s.Error, &s.CapturedAt); err != nil {
return nil, err
}
s.Series = json.RawMessage(series)
if out[alertID] == nil {
out[alertID] = map[string]*snapshotView{}
}
out[alertID][kind] = &s
}
return out, rows.Err()
}
// live queries Prometheus now, reusing an answer younger than metricsLiveTTL.
// The cache is per alert; the window start is fixed by the alert, only its end
// moves, so one entry serves every viewer.
func (m *metricsService) live(ctx context.Context, alertID int64, generatorURL string, labels map[string]string, startsAt int64) *liveMetrics {
m.mu.Lock()
if e, ok := m.cache[alertID]; ok && time.Since(e.at) < metricsLiveTTL {
m.mu.Unlock()
return e.live
}
m.mu.Unlock()
now := time.Now()
start, end := m.window(startsAt, sql.NullInt64{}, now)
step := prom.StepFor(start, end)
lm := &liveMetrics{WindowStart: start.Unix(), WindowEnd: end.Unix(), Step: int(step / time.Second),
Series: []prom.Series{}, Current: []prom.Series{}}
base, expr, ok := prom.ParseGeneratorURL(generatorURL)
if !ok {
lm.Error = errNotGraphLink.Error()
return lm
}
series, err := m.client.QueryRange(ctx, base, expr, start, end, step)
if err != nil {
lm.Error = err.Error()
} else {
lm.Series = prom.SelectSeries(series, labels)
if cur, err := m.client.Query(ctx, base, expr, now); err == nil {
lm.Current = prom.SelectSeries(cur, labels)
} else {
lm.Error = err.Error()
}
}
// A failed answer is cached too, for the same TTL: a Prometheus that is down
// should not be hit once per viewer per poll.
m.mu.Lock()
for k, e := range m.cache {
if time.Since(e.at) > 10*metricsLiveTTL {
delete(m.cache, k)
}
}
m.cache[alertID] = liveEntry{at: now, live: lm}
m.mu.Unlock()
return lm
}
+189
View File
@@ -0,0 +1,189 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// fakePrometheus answers the two query endpoints and counts the requests.
func fakePrometheus(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/query_range":
w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[
{"metric":{"instance":"web-1"},"values":[[100,"1"],[160,"2"]]},
{"metric":{"instance":"web-2"},"values":[[100,"9"]]}]}}`))
case "/api/v1/query":
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"instance":"web-1"},"value":[170,"3"]}]}}`))
default:
w.WriteHeader(404)
}
}))
t.Cleanup(srv.Close)
return srv, &hits
}
type metricsBody struct {
Enabled bool `json:"enabled"`
Alerts []struct {
AlertID int64 `json:"alert_id"`
Expr string `json:"expr"`
Available bool `json:"available"`
Reason string `json:"reason"`
Snapshots map[string]struct {
Series json.RawMessage `json:"series"`
Error string `json:"error"`
} `json:"snapshots"`
Live *struct {
Series []struct{ Labels map[string]string } `json:"series"`
Current []struct{ Labels map[string]string } `json:"current"`
} `json:"live"`
} `json:"alerts"`
}
func getMetrics(t *testing.T, s *ts, id int) metricsBody {
t.Helper()
var out metricsBody
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+itoa(id)+"/metrics", nil), &out)
return out
}
func itoa(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
func metricsAlert(promURL string) map[string]any {
a := amAlert("fp-metrics", "HighLatency", "firing", "2026-05-20T10:00:00Z", "0001-01-01T00:00:00Z",
map[string]string{"instance": "web-1"})
a["generatorURL"] = promURL + "/graph?g0.expr=latency%3E1&g0.tab=1"
return a
}
func TestMetrics_SnapshotsOpenedAndClosedAndLive(t *testing.T) {
prom, _ := fakePrometheus(t)
conf := testConfig()
conf.PrometheusAllowedHosts = []string{"127.0.0.0/8"} // httptest listens on loopback
s := newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{}, conf)
postWebhook(t, s, []map[string]any{metricsAlert(prom.URL)}, "g-metrics")
incs := listIncidents(t, s, "")
if len(incs) != 1 {
t.Fatalf("want one incident, got %d", len(incs))
}
id := int(incs[0]["id"].(float64))
api.MetricsSweep(context.Background(), s.db, conf)
m := getMetrics(t, s, id)
if !m.Enabled || len(m.Alerts) != 1 {
t.Fatalf("metrics = %+v", m)
}
a := m.Alerts[0]
if !a.Available || a.Expr != "latency>1" {
t.Fatalf("alert = %+v", a)
}
opened, ok := a.Snapshots["opened"]
if !ok || opened.Error != "" {
t.Fatalf("opened snapshot = %+v", a.Snapshots)
}
var series []struct{ Labels map[string]string }
json.Unmarshal(opened.Series, &series)
if len(series) != 1 || series[0].Labels["instance"] != "web-1" {
t.Errorf("snapshot should hold only this alert's series (web-1), got %+v", series)
}
if _, ok := a.Snapshots["closed"]; ok {
t.Error("closed snapshot exists while the incident is open")
}
if a.Live == nil || len(a.Live.Series) != 1 || len(a.Live.Current) != 1 {
t.Errorf("an open incident should have live data, got %+v", a.Live)
}
// A second pass must not capture again.
var n int
s.db.QueryRow("SELECT count(*) FROM incident_metric_snapshots").Scan(&n)
api.MetricsSweep(context.Background(), s.db, conf)
var n2 int
s.db.QueryRow("SELECT count(*) FROM incident_metric_snapshots").Scan(&n2)
if n != 1 || n2 != 1 {
t.Errorf("snapshot rows = %d then %d, want 1 and 1", n, n2)
}
// Resolving the incident gets the closed snapshot, and no more live data.
resp := s.req(t, http.MethodPost, "/api/incidents/"+itoa(id)+"/resolve", nil)
resp.Body.Close()
api.MetricsSweep(context.Background(), s.db, conf)
m = getMetrics(t, s, id)
a = m.Alerts[0]
if _, ok := a.Snapshots["closed"]; !ok {
t.Errorf("no closed snapshot after resolve: %+v", a.Snapshots)
}
if a.Live != nil {
t.Error("a resolved incident should not query live")
}
}
func TestMetrics_DisabledWithoutAllowedHosts(t *testing.T) {
prom, hits := fakePrometheus(t)
s := newTS(t)
postWebhook(t, s, []map[string]any{metricsAlert(prom.URL)}, "g-off")
id := int(listIncidents(t, s, "")[0]["id"].(float64))
api.MetricsSweep(context.Background(), s.db, testConfig())
m := getMetrics(t, s, id)
if m.Enabled || len(m.Alerts) != 0 {
t.Errorf("metrics = %+v, want disabled and empty", m)
}
if hits.Load() != 0 {
t.Errorf("Prometheus was contacted %d times", hits.Load())
}
}
func TestMetrics_UnlistedHostIsRecordedNotQueried(t *testing.T) {
prom, hits := fakePrometheus(t)
conf := testConfig()
conf.PrometheusAllowedHosts = []string{"*.example.com"}
s := newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{}, conf)
postWebhook(t, s, []map[string]any{metricsAlert(prom.URL)}, "g-unlisted")
id := int(listIncidents(t, s, "")[0]["id"].(float64))
api.MetricsSweep(context.Background(), s.db, conf)
api.MetricsSweep(context.Background(), s.db, conf) // must not retry a permanent failure
m := getMetrics(t, s, id)
a := m.Alerts[0]
if a.Available || a.Reason == "" {
t.Errorf("alert should be unavailable with a reason: %+v", a)
}
if hits.Load() != 0 {
t.Errorf("an unlisted host was contacted %d times", hits.Load())
}
var attempts int
var errText *string
s.db.QueryRow("SELECT attempts, error FROM incident_metric_snapshots").Scan(&attempts, &errText)
if errText == nil || attempts < 4 {
t.Errorf("failure should be recorded as final: attempts=%d error=%v", attempts, errText)
}
}
func TestMetrics_IncidentScoping(t *testing.T) {
conf := testConfig()
conf.PrometheusAllowedHosts = []string{"127.0.0.0/8"}
s := newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{}, conf)
resp := s.req(t, http.MethodGet, "/api/incidents/9999/metrics", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown incident: %d, want 404", resp.StatusCode)
}
}
+8
View File
@@ -28,6 +28,8 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
signupLimiter := newLoginLimiter(db)
oidcLimit := newLoginLimiter(db)
metrics := newMetricsService(cfg)
r := chi.NewRouter()
r.Use(requestLogger)
r.Use(middleware.Recoverer)
@@ -115,6 +117,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// set up" that the person holding the phone can confirm.
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
r.Put("/api/users/{id}/password", handleSetPassword(db))
// The picture itself is readable by anyone signed in (like the user
// list), changeable only by its owner or an administrator.
r.Get("/api/users/{id}/avatar", handleGetAvatar(db))
r.Put("/api/users/{id}/avatar", handleSetAvatar(db))
r.Delete("/api/users/{id}/avatar", handleDeleteAvatar(db))
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
@@ -149,6 +156,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/incidents/clusters", handleListClusters(db))
r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
r.Get("/api/incidents/{id}/metrics", handleIncidentMetrics(db, metrics))
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
+28
View File
@@ -490,3 +490,31 @@ func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
}
}
// The operator reconciles a team's dead man's switches by listing them, and its
// instance-scoped account is not a member of any team it manages.
func TestServiceAccount_InstanceScopeListsATeamsDeadmanSwitches(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
team := createTeamAs(t, s, instanceKey, "team-a")
path := "/api/teams/" + id64(team) + "/deadman/switches"
resp := s.reqAs(t, instanceKey, http.MethodPost, path,
map[string]any{"name": "watchdog", "matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create switch: %d", resp.StatusCode)
}
resp.Body.Close()
resp = s.reqAs(t, instanceKey, http.MethodGet, path, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list switches as the instance account: %d", resp.StatusCode)
}
var list []struct {
Name string `json:"name"`
}
decode(t, resp, &list)
if len(list) != 1 || list[0].Name != "watchdog" {
t.Errorf("expected the one switch just created, got %+v", list)
}
}
+4 -1
View File
@@ -826,7 +826,10 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
if !requireTeamMember(w, r, teamID) {
// The operator's instance-scoped account reconciles the switches by name,
// so it has to read them without being a member: that is owner reach,
// the same as the writes below, and still reads no incidents.
if !isInstanceServiceAccount(r.Context()) && !requireTeamMember(w, r, teamID) {
return
}
+4 -4
View File
@@ -117,7 +117,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion); err != nil {
serverError(w, r, err)
return
}
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
if err != nil {
return u, err
}
+23
View File
@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/prom"
)
// MinOperatorKeyLength is the shortest TERDUT_OPERATOR_KEY accepted: it is a
@@ -79,6 +81,20 @@ type Config struct {
// "where this server is plugged in" — it is a statement about who owns
// this install's configuration, not a per-request toggle.
OperatorMode bool
// PrometheusAllowedHosts lists the Prometheus servers this install may query
// for the metrics behind an alert, matched against the host in each alert's
// generatorURL. Entries are host names (with "*" and a leading "**" as
// wildcards), IPs or CIDR ranges, optionally with a port; see prom.Allowlist.
// Empty turns the feature off, which is the default: the URL comes from a
// webhook payload, so querying it is opt-in.
PrometheusAllowedHosts []string
// PrometheusPreroll is how far before an alert started its graph begins.
PrometheusPreroll time.Duration
// PrometheusTimeout bounds one request to a Prometheus.
PrometheusTimeout time.Duration
}
// OIDC is the single sign-on configuration. Groups from the provider decide
@@ -151,6 +167,10 @@ func Load() Config {
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
PrometheusAllowedHosts: list("TERDUT_PROMETHEUS_ALLOWED_HOSTS", ""),
PrometheusPreroll: duration("TERDUT_PROMETHEUS_PREROLL", time.Hour),
PrometheusTimeout: duration("TERDUT_PROMETHEUS_TIMEOUT", 10*time.Second),
}
}
@@ -180,6 +200,9 @@ func (c Config) Validate() error {
if c.OperatorKey != "" && len(c.OperatorKey) < MinOperatorKeyLength {
return fmt.Errorf("TERDUT_OPERATOR_KEY must be at least %d characters", MinOperatorKeyLength)
}
if _, err := prom.ParseAllowlist(c.PrometheusAllowedHosts); err != nil {
return fmt.Errorf("TERDUT_PROMETHEUS_ALLOWED_HOSTS: %w", err)
}
o := c.OIDC
if !o.Enabled() {
if c.DisablePasswordLogin {
@@ -0,0 +1,25 @@
-- Metrics captured from the Prometheus an alert came from (its generatorURL),
-- once when the incident opens and once when it closes, so the graph outlives
-- Prometheus' own retention. One row per incident, alert and kind.
--
-- A row exists whether or not the capture worked: a failure is recorded in
-- error with the attempt count, which is what stops the worker from retrying a
-- host that is not on the allowlist (or is gone) forever.
CREATE TABLE incident_metric_snapshots (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
incident_id bigint NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
alert_id bigint NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('opened', 'closed')),
expr text NOT NULL DEFAULT '',
window_start bigint NOT NULL,
window_end bigint NOT NULL,
step_seconds integer NOT NULL DEFAULT 0,
series jsonb NOT NULL DEFAULT '[]'::jsonb,
error text,
attempts integer NOT NULL DEFAULT 1,
captured_at bigint NOT NULL,
UNIQUE (incident_id, alert_id, kind)
);
CREATE INDEX incident_metric_snapshots_alert_id_idx ON incident_metric_snapshots (alert_id);
@@ -0,0 +1,11 @@
-- A user's profile picture: one already-cropped, re-encoded 256x256 PNG per
-- user. Kept in Postgres rather than on a volume so every replica serves it and
-- the pg_dump backup covers it. updated_at is in milliseconds, not seconds like
-- the other timestamps, because it doubles as the cache-busting version of the
-- image URL and a re-upload within the same second must change it.
CREATE TABLE user_avatars (
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data bytea NOT NULL,
updated_at bigint NOT NULL
);
+5
View File
@@ -19,6 +19,11 @@ type User struct {
// every acknowledgement and timeline entry they made.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// AvatarVersion identifies the user's current profile picture, or is nil
// when they have none and clients show their initials. It changes on every
// upload, so it goes in the image URL (?v=) as a cache buster.
AvatarVersion *int64 `json:"avatar_version,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
+268
View File
@@ -0,0 +1,268 @@
// Package prom fetches metrics from the Prometheus an alert came from.
//
// The address to query is the alert's generatorURL, which arrives in a webhook
// payload and is therefore chosen by whoever holds an integration key. Left
// unchecked, that would let the key holder make this server issue requests to
// any host it can reach. Everything here is built around the Allowlist so that
// cannot happen: a host is queried only if the operator listed it.
package prom
import (
"fmt"
"net"
"net/netip"
"net/url"
"path"
"strings"
)
// Allowlist is the set of Prometheus servers this install may query. The zero
// value, and an empty list, permit nothing, which is what keeps the feature off
// until an operator opts in.
//
// An entry is one of:
//
// - a host name, optionally with a port: prometheus.monitoring.svc, prom:9090
// - a wildcard host name: "*" stands for exactly one DNS label (or part of
// one), so *.example.com matches a.example.com but neither example.com nor
// a.b.example.com, and prometheus-*.monitoring.svc matches prometheus-0.
// A leading "**" label matches one or more labels, so **.example.com also
// matches a.b.example.com.
// - an IP address or a CIDR range, optionally with a port: 10.0.0.0/8,
// [fd00::]:9090
//
// Without a port an entry matches any port. Matching is case-insensitive.
type Allowlist struct {
hosts []hostPattern
nets []netEntry
}
type hostPattern struct {
labels []string // lower-cased; a leading "**" label is kept as is
port string // empty matches any port
}
type netEntry struct {
prefix netip.Prefix
port string
}
// ParseAllowlist validates and compiles the configured entries. It refuses an
// entry that would admit every host (a bare "*", "*.*"): a typo there would
// quietly turn the guard off.
func ParseAllowlist(entries []string) (*Allowlist, error) {
a := &Allowlist{}
for _, raw := range entries {
e := strings.ToLower(strings.TrimSpace(raw))
if e == "" {
continue
}
if err := a.add(e); err != nil {
return nil, fmt.Errorf("allowed host %q: %w", raw, err)
}
}
return a, nil
}
func (a *Allowlist) add(e string) error {
// CIDR first: its colons (IPv6) and slash would confuse the host:port split.
if strings.Contains(e, "/") {
host, port := e, ""
if strings.HasPrefix(e, "[") {
end := strings.Index(e, "]")
if end < 0 {
return fmt.Errorf("unterminated [")
}
host = e[1:end]
rest := e[end+1:]
if strings.HasPrefix(rest, ":") {
port = rest[1:]
}
}
p, err := netip.ParsePrefix(host)
if err != nil {
return fmt.Errorf("not a CIDR range: %w", err)
}
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: p.Masked(), port: port})
return nil
}
// A bare IP address, IPv6 included.
if ip, err := netip.ParseAddr(e); err == nil {
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen())})
return nil
}
host, port := e, ""
if strings.HasPrefix(e, "[") || strings.Count(e, ":") == 1 {
h, p, err := net.SplitHostPort(e)
if err != nil {
return fmt.Errorf("bad host:port: %w", err)
}
host, port = h, p
if ip, err := netip.ParseAddr(host); err == nil {
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen()), port: port})
return nil
}
}
if err := checkPort(port); err != nil {
return err
}
host = strings.TrimSuffix(host, ".")
if host == "" {
return fmt.Errorf("empty host")
}
for _, r := range host {
ok := r == '.' || r == '-' || r == '_' || r == '*' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if !ok {
return fmt.Errorf("unexpected character %q", r)
}
}
labels := strings.Split(host, ".")
for i, l := range labels {
if l == "" {
return fmt.Errorf("empty label")
}
if l == "**" && i != 0 {
return fmt.Errorf("** is only allowed as the first label")
}
if strings.Contains(l, "**") && l != "**" {
return fmt.Errorf("** must be a whole label")
}
}
// The last label has to say something, or the entry is allow-all.
if strings.Trim(labels[len(labels)-1], "*") == "" {
return fmt.Errorf("would match every host: the last label cannot be a wildcard alone")
}
a.hosts = append(a.hosts, hostPattern{labels: labels, port: port})
return nil
}
func checkPort(p string) error {
if p == "" {
return nil
}
for _, r := range p {
if r < '0' || r > '9' {
return fmt.Errorf("bad port %q", p)
}
}
return nil
}
// Empty reports whether nothing is allowed, i.e. the feature is off.
func (a *Allowlist) Empty() bool {
return a == nil || (len(a.hosts) == 0 && len(a.nets) == 0)
}
// Port returns the effective port of u: the explicit one, else the scheme's.
func Port(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// permits decides whether host:port may be contacted at ip. host is what the
// URL said (a name or an IP literal), ip is where it actually resolved to; the
// check runs on the resolved address at dial time so that a name that resolves
// somewhere else than it did when it was listed (DNS rebinding) is caught.
//
// - An address inside a listed CIDR range or equal to a listed IP is allowed.
// - A host name that matches a listed pattern is allowed, unless it resolved
// to an address that is never a Prometheus (loopback, link-local, which
// includes the cloud metadata address, unspecified, multicast). A wildcard
// over a domain somebody else controls must not be a way to reach internal
// services. To allow such an address deliberately, list it as a CIDR.
func (a *Allowlist) permits(host, port string, ip netip.Addr) bool {
if a == nil {
return false
}
ip = ip.Unmap()
for _, n := range a.nets {
if n.prefix.Contains(ip) && (n.port == "" || n.port == port) {
return true
}
}
if forbiddenAddr(ip) {
return false
}
if _, isIP := parseHostIP(host); isIP {
return false // an IP literal is allowed only through a CIDR entry
}
return a.matchesName(host, port)
}
func parseHostIP(host string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
return ip, err == nil
}
// MatchesHost reports whether the URL's host is worth dialing at all: a listed
// name, or an IP that falls in a listed range. It is the cheap early answer;
// the dial-time check in permits is the one that is authoritative for names.
func (a *Allowlist) MatchesHost(u *url.URL) bool {
if a.Empty() {
return false
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
port := Port(u)
if ip, ok := parseHostIP(host); ok {
return a.permits(host, port, ip)
}
if a.matchesName(host, port) {
return true
}
// A name outside the patterns can still be admitted by a CIDR entry once it
// resolves; leave that to the dialer.
return len(a.nets) > 0
}
func (a *Allowlist) matchesName(host, port string) bool {
labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".")
for _, p := range a.hosts {
if (p.port == "" || p.port == port) && matchLabels(p.labels, labels) {
return true
}
}
return false
}
func matchLabels(pattern, host []string) bool {
if len(pattern) > 0 && pattern[0] == "**" {
rest := pattern[1:]
if len(host) <= len(rest) {
return false
}
return matchLabels(rest, host[len(host)-len(rest):])
}
if len(pattern) != len(host) {
return false
}
for i := range pattern {
// The pattern was restricted to [a-z0-9_.-*] when parsed, so path.Match
// sees nothing but literals and "*" here.
if ok, _ := path.Match(pattern[i], host[i]); !ok {
return false
}
}
return true
}
// forbiddenAddr is the set of addresses a Prometheus is never reached at and
// that are dangerous to reach by mistake. Private ranges are deliberately not
// in it: a Prometheus inside the cluster lives there.
func forbiddenAddr(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
+274
View File
@@ -0,0 +1,274 @@
package prom
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
const (
// maxResponseBytes bounds what one query may make us read.
maxResponseBytes = 5 << 20
// maxSeries bounds how many series one query may return before we give up on
// it; a rule expression without aggregation can match thousands.
maxSeries = 2000
// TargetPoints is how many points a graph is asked for.
TargetPoints = 300
)
// ErrNotAllowed means the target host is not on the allowlist.
var ErrNotAllowed = errors.New("host is not in the allowed Prometheus hosts")
// Series is one time series, ready to chart. Points are [unix seconds, value].
type Series struct {
Labels map[string]string `json:"labels"`
Points [][2]float64 `json:"points"`
}
// Client queries Prometheus servers that the Allowlist permits.
type Client struct {
http *http.Client
allow *Allowlist
}
// NewClient returns a client that only ever connects to allowed addresses. The
// check is made on the address the dialer is about to use, after name
// resolution, and redirects and environment proxies are off: either would let a
// permitted host send the request somewhere that is not.
func NewClient(allow *Allowlist, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 10 * time.Second
}
dialer := &net.Dialer{Timeout: timeout}
tr := &http.Transport{
Proxy: nil,
DialContext: guardedDial(allow, dialer),
TLSHandshakeTimeout: timeout,
MaxIdleConns: 4,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
allow: allow,
http: &http.Client{
Timeout: timeout,
Transport: tr,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// Enabled reports whether any host is allowed at all.
func (c *Client) Enabled() bool { return c != nil && !c.allow.Empty() }
// Allowed is the early, resolution-free answer: whether base could be queried.
func (c *Client) Allowed(base *url.URL) bool {
return c != nil && c.allow.MatchesHost(base)
}
func guardedDial(allow *Allowlist, d *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var ips []netip.Addr
if ip, err := netip.ParseAddr(host); err == nil {
ips = []netip.Addr{ip}
} else {
ips, err = net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
}
var lastErr error = ErrNotAllowed
for _, ip := range ips {
if !allow.permits(host, port, ip) {
continue
}
// Dial the checked address itself, so a second lookup cannot return
// something different.
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
}
// QueryRange runs expr over [start, end] at the given step.
func (c *Client) QueryRange(ctx context.Context, base *url.URL, expr string, start, end time.Time, step time.Duration) ([]Series, error) {
form := url.Values{
"query": {expr},
"start": {strconv.FormatInt(start.Unix(), 10)},
"end": {strconv.FormatInt(end.Unix(), 10)},
"step": {strconv.FormatInt(int64(step/time.Second), 10)},
}
res, err := c.do(ctx, base, "/api/v1/query_range", form)
if err != nil {
return nil, err
}
if res.ResultType != "matrix" {
return nil, fmt.Errorf("unexpected result type %q", res.ResultType)
}
var raw []struct {
Metric map[string]string `json:"metric"`
Values [][2]json.RawMessage `json:"values"`
}
if err := json.Unmarshal(res.Result, &raw); err != nil {
return nil, fmt.Errorf("decode matrix: %w", err)
}
out := make([]Series, 0, len(raw))
for _, r := range raw {
s := Series{Labels: r.Metric, Points: make([][2]float64, 0, len(r.Values))}
for _, v := range r.Values {
if p, ok := point(v); ok {
s.Points = append(s.Points, p)
}
}
out = append(out, s)
}
return out, nil
}
// Query runs expr at a single instant and returns one point per series.
func (c *Client) Query(ctx context.Context, base *url.URL, expr string, at time.Time) ([]Series, error) {
form := url.Values{
"query": {expr},
"time": {strconv.FormatInt(at.Unix(), 10)},
}
res, err := c.do(ctx, base, "/api/v1/query", form)
if err != nil {
return nil, err
}
if res.ResultType != "vector" {
return nil, fmt.Errorf("unexpected result type %q", res.ResultType)
}
var raw []struct {
Metric map[string]string `json:"metric"`
Value [2]json.RawMessage `json:"value"`
}
if err := json.Unmarshal(res.Result, &raw); err != nil {
return nil, fmt.Errorf("decode vector: %w", err)
}
out := make([]Series, 0, len(raw))
for _, r := range raw {
s := Series{Labels: r.Metric}
if p, ok := point(r.Value); ok {
s.Points = [][2]float64{p}
}
out = append(out, s)
}
return out, nil
}
type apiResult struct {
ResultType string `json:"resultType"`
Result json.RawMessage `json:"result"`
}
func (c *Client) do(ctx context.Context, base *url.URL, apiPath string, form url.Values) (*apiResult, error) {
if !c.Allowed(base) {
return nil, ErrNotAllowed
}
u := *base
u.Path = strings.TrimSuffix(base.Path, "/") + apiPath
// POST, so a long expression is not limited by URL length.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, err
}
if len(body) > maxResponseBytes {
return nil, errors.New("response too large")
}
var env struct {
Status string `json:"status"`
Data *apiResult `json:"data"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("prometheus answered %d with a body that is not JSON", resp.StatusCode)
}
if resp.StatusCode/100 != 2 || env.Status != "success" || env.Data == nil {
msg := env.Error
if msg == "" {
msg = "status " + strconv.Itoa(resp.StatusCode)
}
return nil, fmt.Errorf("prometheus: %s", truncate(msg, 200))
}
if n := countResults(env.Data.Result); n > maxSeries {
return nil, fmt.Errorf("query matches %d series, over the limit of %d", n, maxSeries)
}
return env.Data, nil
}
func countResults(raw json.RawMessage) int {
var items []json.RawMessage
if json.Unmarshal(raw, &items) != nil {
return 0
}
return len(items)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// point decodes a [timestamp, "value"] pair. Prometheus writes values as
// strings so that NaN and ±Inf survive; JSON cannot carry them, so those points
// are dropped.
func point(v [2]json.RawMessage) ([2]float64, bool) {
var ts float64
var s string
if json.Unmarshal(v[0], &ts) != nil || json.Unmarshal(v[1], &s) != nil {
return [2]float64{}, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return [2]float64{}, false
}
return [2]float64{ts, f}, true
}
// StepFor picks a query step that yields about TargetPoints over the span, never
// finer than a second.
func StepFor(start, end time.Time) time.Duration {
span := end.Sub(start)
step := span / TargetPoints
if step < time.Second {
return time.Second
}
return step.Round(time.Second)
}
+51
View File
@@ -0,0 +1,51 @@
package prom
import "sort"
// MaxSeriesPerAlert caps how many series are kept for one alert.
const MaxSeriesPerAlert = 5
// SelectSeries keeps the series that belong to this alert. The rule expression
// returns every series it matches, not just the one that fired, so a series is
// kept only if it agrees with the alert's labels on every label name they both
// carry. Labels only one side has (the rule's severity, the series' __name__)
// say nothing either way, and alertname is the rule's, not the series'.
//
// The result is capped at MaxSeriesPerAlert, best-labelled first: the more
// labels a series shares with the alert, the likelier it is the one that fired.
func SelectSeries(series []Series, alertLabels map[string]string) []Series {
type scored struct {
s Series
score int
}
var kept []scored
for _, s := range series {
score, ok := 0, true
for k, v := range s.Labels {
if k == "__name__" || k == "alertname" {
continue
}
av, has := alertLabels[k]
if !has {
continue
}
if av != v {
ok = false
break
}
score++
}
if ok && len(s.Points) > 0 {
kept = append(kept, scored{s, score})
}
}
sort.SliceStable(kept, func(i, j int) bool { return kept[i].score > kept[j].score })
if len(kept) > MaxSeriesPerAlert {
kept = kept[:MaxSeriesPerAlert]
}
out := make([]Series, len(kept))
for i, k := range kept {
out[i] = k.s
}
return out
}
+38
View File
@@ -0,0 +1,38 @@
package prom
import (
"net/url"
"strings"
)
// ParseGeneratorURL splits an Alertmanager generatorURL into the Prometheus it
// points at and the expression of the rule that fired. Prometheus builds it as
// <external-url>/graph?g0.expr=<expr>&g0.tab=1 (/query on Prometheus 3), and the
// external URL may carry a path prefix, which is kept in base.
//
// ok is false for anything else: a non-HTTP scheme, credentials in the URL, a
// page that is not the graph page, or no expression. The caller then has
// nothing to query and falls back to showing the link.
func ParseGeneratorURL(raw string) (base *url.URL, expr string, ok bool) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" || u.User != nil {
return nil, "", false
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", false
}
prefix := ""
switch {
case strings.HasSuffix(u.Path, "/graph"):
prefix = strings.TrimSuffix(u.Path, "/graph")
case strings.HasSuffix(u.Path, "/query"):
prefix = strings.TrimSuffix(u.Path, "/query")
default:
return nil, "", false
}
expr = strings.TrimSpace(u.Query().Get("g0.expr"))
if expr == "" {
return nil, "", false
}
return &url.URL{Scheme: u.Scheme, Host: u.Host, Path: prefix}, expr, true
}
+237
View File
@@ -0,0 +1,237 @@
package prom
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"testing"
"time"
)
func TestParseAllowlistRejectsAllowAll(t *testing.T) {
for _, e := range []string{"*", "**", "*.*", "**.*", "a.**", "pro**.example.com", "ex ample.com", "host:abc", "10.0.0.0/33"} {
if _, err := ParseAllowlist([]string{e}); err == nil {
t.Errorf("%q was accepted", e)
}
}
}
func TestAllowlistNames(t *testing.T) {
a, err := ParseAllowlist([]string{
"prometheus.monitoring.svc",
"prom-*.monitoring.svc",
"*.example.com",
"**.corp.example",
"fixed.example.org:9090",
})
if err != nil {
t.Fatal(err)
}
pub := netip.MustParseAddr("10.1.2.3")
cases := []struct {
host, port string
want bool
}{
{"prometheus.monitoring.svc", "9090", true},
{"PROMETHEUS.monitoring.svc", "80", true},
{"prom-0.monitoring.svc", "9090", true},
{"prom-0.other.svc", "9090", false},
{"a.example.com", "443", true},
{"example.com", "443", false},
{"a.b.example.com", "443", false},
{"a.corp.example", "80", true},
{"a.b.c.corp.example", "80", true},
{"corp.example", "80", false},
{"fixed.example.org", "9090", true},
{"fixed.example.org", "80", false},
{"evilexample.com", "80", false},
{"a.example.com.evil.net", "80", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, pub); got != c.want {
t.Errorf("permits(%s:%s) = %v, want %v", c.host, c.port, got, c.want)
}
}
}
func TestAllowlistForbiddenAddressesAndCIDR(t *testing.T) {
a, _ := ParseAllowlist([]string{"*.example.com", "192.168.0.0/16", "[fd00::]:9090", "127.0.0.9"})
cases := []struct {
name, host, port, ip string
want bool
}{
{"wildcard to private is fine", "a.example.com", "80", "10.0.0.5", true},
{"wildcard rebinding to loopback", "a.example.com", "80", "127.0.0.1", false},
{"wildcard to metadata", "a.example.com", "80", "169.254.169.254", false},
{"wildcard to unspecified", "a.example.com", "80", "0.0.0.0", false},
{"wildcard to v4-mapped loopback", "a.example.com", "80", "::ffff:127.0.0.1", false},
{"cidr literal", "192.168.4.5", "80", "192.168.4.5", true},
{"outside cidr", "10.9.9.9", "80", "10.9.9.9", false},
{"cidr admits unlisted name", "anything.test", "80", "192.168.1.1", true},
{"explicit loopback entry", "127.0.0.9", "80", "127.0.0.9", true},
{"other loopback", "127.0.0.1", "80", "127.0.0.1", false},
{"ipv6 with port ok", "fd00::1", "9090", "fd00::1", false}, // entry is a single address fd00::, not the range
{"ipv6 exact", "fd00::", "9090", "fd00::", true},
{"ipv6 wrong port", "fd00::", "80", "fd00::", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, netip.MustParseAddr(c.ip)); got != c.want {
t.Errorf("%s: permits = %v, want %v", c.name, got, c.want)
}
}
}
func TestEmptyAllowlistPermitsNothing(t *testing.T) {
var nilList *Allowlist
empty, _ := ParseAllowlist(nil)
for _, a := range []*Allowlist{nilList, empty} {
if !a.Empty() || a.permits("x.example.com", "80", netip.MustParseAddr("10.0.0.1")) {
t.Error("empty allowlist permitted something")
}
}
}
func TestParseGeneratorURL(t *testing.T) {
cases := []struct {
raw string
wantBase string
wantExpr string
ok bool
}{
{"http://prom:9090/graph?g0.expr=up%3D%3D0&g0.tab=1", "http://prom:9090", "up==0", true},
{"https://p.example.com/prom/graph?g0.expr=rate%28x%5B5m%5D%29%3E1", "https://p.example.com/prom", "rate(x[5m])>1", true},
{"https://p.example.com/query?g0.expr=up", "https://p.example.com", "up", true},
{"http://prom:9090/graph?g0.tab=1", "", "", false},
{"http://prom:9090/other?g0.expr=up", "", "", false},
{"ftp://prom/graph?g0.expr=up", "", "", false},
{"http://user:pw@prom/graph?g0.expr=up", "", "", false},
{"", "", "", false},
{"not a url", "", "", false},
}
for _, c := range cases {
base, expr, ok := ParseGeneratorURL(c.raw)
if ok != c.ok || expr != c.wantExpr || (ok && base.String() != c.wantBase) {
t.Errorf("%q = (%v, %q, %v), want (%q, %q, %v)", c.raw, base, expr, ok, c.wantBase, c.wantExpr, c.ok)
}
}
}
func TestSelectSeries(t *testing.T) {
mk := func(l map[string]string) Series { return Series{Labels: l, Points: [][2]float64{{1, 1}}} }
series := []Series{
mk(map[string]string{"__name__": "up", "instance": "a", "job": "x"}),
mk(map[string]string{"__name__": "up", "instance": "b", "job": "x"}),
mk(map[string]string{"instance": "a", "job": "y"}),
{Labels: map[string]string{"instance": "a"}}, // no points
}
got := SelectSeries(series, map[string]string{"alertname": "Down", "instance": "a", "job": "x", "severity": "page"})
if len(got) != 1 || got[0].Labels["job"] != "x" || got[0].Labels["instance"] != "a" {
t.Fatalf("got %+v", got)
}
if n := len(SelectSeries(series, nil)); n != 3 {
t.Errorf("no alert labels should keep every series with points, got %d", n)
}
}
func TestStepFor(t *testing.T) {
now := time.Now()
if s := StepFor(now.Add(-time.Minute), now); s != time.Second {
t.Errorf("short span step = %v", s)
}
if s := StepFor(now.Add(-5*time.Hour), now); s != time.Minute {
t.Errorf("5h step = %v, want 1m", s)
}
}
func fakeProm(t *testing.T) (*httptest.Server, *int) {
t.Helper()
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
r.ParseForm()
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/query_range":
w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[
{"metric":{"instance":"a"},"values":[[100,"1"],[110,"NaN"],[120,"2.5"]]}]}}`))
case "/api/v1/query":
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"instance":"a"},"value":[130,"3"]}]}}`))
case "/redirect/api/v1/query":
http.Redirect(w, r, "http://127.0.0.1:1/", http.StatusFound)
default:
w.WriteHeader(400)
w.Write([]byte(`{"status":"error","errorType":"bad_data","error":"nope"}`))
}
}))
t.Cleanup(srv.Close)
return srv, &hits
}
func TestClientQueries(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL)
// httptest listens on loopback, which is refused unless listed as a CIDR.
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
c := NewClient(allow, 5*time.Second)
got, err := c.QueryRange(context.Background(), u, "up", time.Unix(100, 0), time.Unix(120, 0), 10*time.Second)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[0].Points) != 2 || got[0].Points[1] != [2]float64{120, 2.5} {
t.Fatalf("range = %+v (NaN point should be dropped)", got)
}
inst, err := c.Query(context.Background(), u, "up", time.Unix(130, 0))
if err != nil || len(inst) != 1 || inst[0].Points[0][1] != 3 {
t.Fatalf("instant = %+v, %v", inst, err)
}
}
func TestClientRefusesUnlistedAndLoopback(t *testing.T) {
srv, hits := fakeProm(t)
u, _ := url.Parse(srv.URL)
// Listed by name, but the name resolves to loopback: refused at dial time.
local, _ := url.Parse("http://localhost:" + u.Port())
wild, _ := ParseAllowlist([]string{"local*"})
if _, err := NewClient(wild, time.Second).Query(context.Background(), local, "up", time.Now()); err == nil {
t.Error("wildcard-matched name resolving to loopback was queried")
}
// Not listed at all.
other, _ := ParseAllowlist([]string{"*.example.com"})
if _, err := NewClient(other, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("unlisted host: err = %v", err)
}
// Disabled.
if _, err := NewClient(nil, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("disabled: err = %v", err)
}
if *hits != 0 {
t.Errorf("server was contacted %d times", *hits)
}
}
func TestClientDoesNotFollowRedirects(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/redirect")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
if _, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now()); err == nil {
t.Error("redirect response was treated as success")
}
}
func TestClientSurfacesPrometheusError(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/missing")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
_, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now())
if err == nil {
t.Fatal("expected an error")
}
}
+53
View File
@@ -7,6 +7,7 @@
:root {
--bg: #f5f6f8;
--dot: rgba(60, 70, 95, 0.16);
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
@@ -79,6 +80,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--dot: rgba(148, 156, 180, 0.26);
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
@@ -114,6 +116,7 @@
}
:root[data-theme="dark"] {
--bg: #0f1115;
--dot: rgba(148, 156, 180, 0.26);
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
@@ -163,6 +166,17 @@ body {
-webkit-tap-highlight-color: transparent;
}
/* Dot grid behind everything, fading out down the screen. z-index -1 sits above
the canvas background but below all in-flow content, so no wrapper element
is needed. */
body::before {
content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
background-image: radial-gradient(circle, var(--dot) 1.25px, transparent 1.75px);
background-size: 28px 28px;
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
code { font-family: var(--mono); font-size: 0.9em; }
@@ -586,6 +600,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.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; }
/* Metrics under an alert */
.metrics { display: grid; gap: 6px; }
.metrics-note { color: var(--faint); font-size: 13px; }
.metrics-now { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
.metrics-now strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.metrics-now.none { color: var(--muted); }
.metrics-now-label { color: var(--faint); font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
.metric-chart { width: 100%; height: auto; display: block; }
.mc-grid { stroke: var(--border); stroke-width: 1; }
.mc-label { fill: var(--faint); font-size: 10px; }
.mc-marker { stroke-width: 1; stroke-dasharray: 3 3; }
.mc-marker.start { stroke: var(--crit); }
.mc-marker.end { stroke: var(--ok); }
.mc-line { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; }
.mc-line.s0, .mc-swatch.s0 { stroke: var(--accent); background: var(--accent); }
.mc-line.s1, .mc-swatch.s1 { stroke: var(--warn); background: var(--warn); }
.mc-line.s2, .mc-swatch.s2 { stroke: var(--ok); background: var(--ok); }
.mc-line.s3, .mc-swatch.s3 { stroke: var(--crit); background: var(--crit); }
.mc-line.s4, .mc-swatch.s4 { stroke: var(--muted); background: var(--muted); }
.mc-dot.s0 { fill: var(--accent); } .mc-dot.s1 { fill: var(--warn); } .mc-dot.s2 { fill: var(--ok); }
.mc-dot.s3 { fill: var(--crit); } .mc-dot.s4 { fill: var(--muted); }
.mc-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; }
.metrics-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; color: var(--muted); }
.metrics-legend li { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
.metrics-expr { font-size: 12px; color: var(--faint); overflow-wrap: anywhere; }
/* A disclosure is a button: a chevron that turns, and a 44px target on a
touch screen (the bare triangle it replaces was a few pixels wide). */
details > summary {
@@ -748,6 +789,18 @@ details[open] > summary { margin-bottom: 8px; }
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
/* A picture lies over the initial, which stays underneath as the fallback. */
.avatar, .rota-chip, .nav-avatar { position: relative; overflow: hidden; }
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.avatar-sm { width: 24px; height: 24px; font-size: 12px; }
.avatar-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.crop-stage { position: relative; width: 256px; max-width: 100%; aspect-ratio: 1; margin: 0 auto 12px; overflow: hidden; border-radius: var(--radius); background: var(--surface-2); touch-action: none; }
.crop-canvas { display: block; width: 100%; height: 100%; cursor: grab; }
.crop-canvas:active { cursor: grabbing; }
/* The circle the picture will be shown in: everything outside it is dimmed. */
.crop-mask { position: absolute; inset: 0; border-radius: 50%; box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.5); pointer-events: none; }
.crop-zoom { display: grid; gap: 4px; width: 256px; max-width: 100%; margin: 0 auto 12px; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
is easier to spot there than this plain accent-coloured text. */
+129 -5
View File
@@ -1,10 +1,11 @@
// 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 { h, clear, icon, toast, openSheet, closeSheet } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.js';
import { state, forgetUsers } from './state.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
import { signOut, renderAccountNav } from './app.js';
const view = () => document.getElementById('view-more');
@@ -24,10 +25,11 @@ function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id),
h('div', {},
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'account-email', text: user.email }),
avatarControls(user))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyCard(user, providers),
@@ -299,3 +301,125 @@ function shortcuts() {
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
h('td', { text: v })))));
}
// ---------- profile picture ----------
const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1
function avatarControls(user) {
const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true });
input.addEventListener('change', async () => {
const file = input.files[0];
input.value = '';
if (!file) return;
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
toast('That file is not an image this browser can read.', 'error');
return;
}
const blob = await cropSheet(bitmap);
if (!blob) return;
try {
const { avatar_version: v } = await api.setAvatar(user.id, blob);
changed(user, v);
toast('Picture updated');
} catch (err) {
toast(err.message, 'error');
}
});
const remove = async () => {
try {
await api.removeAvatar(user.id);
changed(user, null);
toast('Picture removed');
} catch (err) {
toast(err.message, 'error');
}
};
return h('div', { class: 'avatar-actions' },
input,
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }),
user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' }));
}
// The picture changed: keep the cached account and user list in step and repaint.
function changed(user, version) {
user.avatar_version = version ?? undefined;
setAvatarVersion(user.id, version);
forgetUsers();
renderAccountNav();
show();
}
// A square stage with the image under it: drag to move, slider or wheel to
// zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or
// null if dismissed.
function cropSheet(bitmap) {
const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage
let zoom = 1;
let cx = bitmap.width / 2; // the image point shown at the stage's centre
let cy = bitmap.height / 2;
const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP });
const clamp = () => {
const half = CROP / 2 / (base * zoom);
cx = Math.min(bitmap.width - half, Math.max(half, cx));
cy = Math.min(bitmap.height - half, Math.max(half, cy));
};
const paint = (ctx = canvas.getContext('2d'), size = CROP) => {
clamp();
const s = base * zoom * (size / CROP);
ctx.clearRect(0, 0, size, size);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s);
};
const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' });
slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); });
let drag = null;
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('pointermove', (e) => {
if (!drag) return;
const k = CROP / canvas.getBoundingClientRect().width / (base * zoom);
cx -= (e.clientX - drag.x) * k;
cy -= (e.clientY - drag.y) * k;
drag = { x: e.clientX, y: e.clientY };
paint();
});
const end = () => { drag = null; };
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)));
slider.value = String(zoom);
paint();
}, { passive: false });
return openSheet(() => {
queueMicrotask(() => paint());
return [
h('h2', { class: 'sheet-title', text: 'Crop your picture' }),
h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })),
h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', {
class: 'btn btn-primary', type: 'button', text: 'Save',
onclick: () => {
const out = document.createElement('canvas');
out.width = CROP;
out.height = CROP;
paint(out.getContext('2d'));
out.toBlob((b) => closeSheet(b), 'image/png');
},
})),
];
});
}
+25
View File
@@ -72,6 +72,30 @@ export const setPassword = (userID, password, currentPassword) =>
// users
export const users = () => call('GET', '/users');
// A profile picture is raw image bytes, not JSON, so it does not go through call.
export async function setAvatar(userID, blob) {
let resp;
try {
resp = await fetch(`/api/users/${userID}/avatar`, {
method: 'PUT', headers: { 'Content-Type': blob.type || 'image/png' }, body: blob, credentials: 'same-origin',
});
} catch {
throw new ApiError(0, 'Cannot reach the server.');
}
let data = null;
try {
data = await resp.json();
} catch {
/* keep null */
}
if (!resp.ok) {
if (resp.status === 401) onUnauthorized();
throw new ApiError(resp.status, (data && data.error) || `Server answered ${resp.status}.`);
}
return data;
}
export const removeAvatar = (userID) => call('DELETE', `/users/${userID}/avatar`);
// What one person is in. /teams answers "what am I in" and cannot be asked
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
@@ -108,6 +132,7 @@ 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, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
export const metrics = (id) => call('GET', `/incidents/${id}/metrics`);
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
+9 -3
View File
@@ -3,7 +3,7 @@
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 { state, reset, loadTeams, users as ui_users } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
@@ -17,6 +17,7 @@ import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
const $ = (id) => document.getElementById(id);
@@ -473,10 +474,13 @@ export async function signOut() {
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
export function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').replaceChildren(initial(name));
$('nav-avatar').classList.remove('has-img');
setAvatarVersion(state.me.user.id, state.me.user.avatar_version);
withAvatar($('nav-avatar'), state.me.user.id);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
@@ -488,6 +492,8 @@ function showApp() {
$('login').hidden = true;
$('app').hidden = false;
render();
// Who has a picture: the first paint shows initials, the list repaints it.
ui_users().then(render).catch(() => {});
poll.start(refresh);
poll.now();
}
+35
View File
@@ -0,0 +1,35 @@
// Profile pictures. A user either has one (the server says so with an
// avatar_version, which is also the cache buster in the image URL) or shows
// their initial in a coloured circle, as before.
const versions = new Map(); // user id -> avatar_version
// Learn from a user list (or a single user) which people have a picture.
export function noteAvatars(list) {
for (const u of list || []) setAvatarVersion(u.id, u.avatar_version);
}
export function setAvatarVersion(userID, version) {
if (version == null) versions.delete(userID);
else versions.set(userID, version);
}
export function clearAvatars() {
versions.clear();
}
// withAvatar lays the person's picture over el, which keeps its initial
// underneath: if the image fails to load, the initial is what shows.
export function withAvatar(el, userID) {
const v = versions.get(userID);
if (v == null) return el;
el.classList.add('has-img');
const img = document.createElement('img');
img.className = 'avatar-img';
img.alt = '';
img.decoding = 'async';
img.src = `/api/users/${userID}/avatar?v=${v}`;
img.addEventListener('error', () => img.remove());
el.append(img);
return el;
}
+13 -4
View File
@@ -7,10 +7,12 @@ import {
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial,
} from './format.js';
import { myID, users } from './state.js';
import { withAvatar } from './avatar.js';
import { back } from './app.js';
import { alertMetrics } from './metricchart.js';
const pane = () => document.getElementById('detail');
@@ -18,6 +20,7 @@ let currentID = null;
let inc = null;
let events = [];
let similarList = [];
let metricsByAlert = {};
let error = null;
let busy = false;
@@ -26,6 +29,7 @@ export function show(id) {
currentID = id;
inc = null;
events = [];
metricsByAlert = {};
error = null;
if (id == null) {
renderPlaceholder();
@@ -41,13 +45,17 @@ export async function refresh() {
try {
// Similar incidents are a courtesy: an older server answers 404 and a
// failure here must not hide the incident itself.
const [i, t, sim] = await Promise.all([
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
// Metrics are a courtesy too: an older server has no such endpoint, and a
// Prometheus that is slow or down must not hold up the incident.
const [i, t, sim, met] = await Promise.all([
api.incident(id), api.timeline(id), api.similar(id).catch(() => []), api.metrics(id).catch(() => null),
]);
if (id !== currentID) return;
inc = i;
events = t;
similarList = sim;
metricsByAlert = {};
if (met && met.enabled) for (const m of met.alerts) metricsByAlert[m.alert_id] = m;
error = null;
} catch (err) {
if (id !== currentID) return;
@@ -203,6 +211,7 @@ function alertItem(a) {
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 }),
alertMetrics(metricsByAlert[a.id], a.starts_at, inc.resolved_at),
h('div', { class: 'alert-item-foot' },
h('span', { text: `Started ${ago(a.starts_at)}` }),
h('span', { text: `Last seen ${ago(a.received_at)}` }),
@@ -643,7 +652,7 @@ async function assign() {
'aria-checked': String(u.id === inc.assigned_to_id),
onclick: () => closeSheet(u.id),
},
icon('user'),
withAvatar(h('span', { class: 'avatar avatar-sm', text: initial(u.username) }), u.id),
u.id === me ? `${u.username} (you)` : u.username,
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
)))),
+196
View File
@@ -0,0 +1,196 @@
// The metrics under one alert: a line chart of the expression that fired it, and
// what that expression reads right now. The data comes from this server, which
// asked the alert's own Prometheus (see GET /api/incidents/{id}/metrics); the
// browser never talks to Prometheus.
import { h } from './ui.js';
import { when } from './format.js';
const SVG_NS = 'http://www.w3.org/2000/svg';
const MAX_SERIES = 5;
function svg(tag, attrs = {}, text) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text != null) el.textContent = text;
return el;
}
const iso = (sec) => new Date(sec * 1000).toISOString();
// Compact number for an axis or a badge: 1.2k, 0.034, 12.
export function fmtValue(v) {
if (v == null || Number.isNaN(v)) return '–';
const a = Math.abs(v);
if (a >= 1e9) return `${+(v / 1e9).toPrecision(3)}G`;
if (a >= 1e6) return `${+(v / 1e6).toPrecision(3)}M`;
if (a >= 1e4) return `${+(v / 1e3).toPrecision(3)}k`;
if (a === 0) return '0';
if (a >= 100) return String(Math.round(v));
return String(+v.toPrecision(3));
}
const keyOf = (labels) => JSON.stringify(Object.entries(labels || {}).sort());
// seriesName is the labels that tell the series apart, e.g. {instance="web-1"}.
function seriesName(labels) {
const parts = Object.entries(labels || {}).filter(([k]) => k !== '__name__').map(([k, v]) => `${k}="${v}"`);
return parts.length ? `{${parts.join(', ')}}` : 'value';
}
// merge combines the snapshot taken when the incident opened with the live
// window, so the chart keeps history Prometheus may since have dropped. On the
// same timestamp the later source wins.
function merge(...sources) {
const byKey = new Map();
for (const src of sources) {
for (const s of src || []) {
const k = keyOf(s.labels);
const cur = byKey.get(k) || { labels: s.labels, points: new Map() };
for (const [t, v] of s.points || []) cur.points.set(t, v);
byKey.set(k, cur);
}
}
return [...byKey.values()].map((s) => ({
labels: s.labels,
points: [...s.points.entries()].sort((a, b) => a[0] - b[0]),
}));
}
function parseSeries(raw) {
if (Array.isArray(raw)) return raw;
return [];
}
// pick chooses what to draw: while the incident is open, the opened snapshot
// plus live data; once it is closed, the closed snapshot (the whole incident),
// falling back to the opened one.
function pick(entry) {
const snaps = entry.snapshots || {};
const live = entry.live;
if (live && !live.error) {
return {
series: merge(parseSeries(snaps.opened && snaps.opened.series), live.series),
from: Math.min(live.window_start, snaps.opened ? snaps.opened.window_start : Infinity),
to: live.window_end,
current: live.current || [],
isLive: true,
};
}
const snap = snaps.closed || snaps.opened;
if (snap && !snap.error) {
return {
series: merge(parseSeries(snap.series)), from: snap.window_start, to: snap.window_end, current: [], isLive: false,
};
}
return null;
}
// alertMetrics renders the metrics block for one alert, or null when there is
// nothing worth saying (the feature off, or no Prometheus link on the alert).
export function alertMetrics(entry, startsAtISO, resolvedISO) {
if (!entry) return null;
if (!entry.available) {
return entry.reason && entry.expr
? h('div', { class: 'metrics-note', text: `Metrics unavailable: ${entry.reason}.` })
: null;
}
const data = pick(entry);
if (!data) {
const err = (entry.live && entry.live.error)
|| (entry.snapshots && ((entry.snapshots.closed && entry.snapshots.closed.error) || (entry.snapshots.opened && entry.snapshots.opened.error)));
return h('div', { class: 'metrics-note', text: err ? `Metrics could not be fetched: ${err}` : 'Metrics are being collected…' });
}
const startSec = startsAtISO ? Date.parse(startsAtISO) / 1000 : null;
const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null;
return h('div', { class: 'metrics' },
currentLine(entry, data),
data.series.length ? chart(data, startSec, endSec) : h('div', { class: 'metrics-note', text: 'No samples in this window.' }),
legend(data.series),
entry.expr && h('code', { class: 'metrics-expr', text: entry.expr }),
);
}
function currentLine(entry, data) {
if (!data.isLive) return null;
const vals = data.current.map((s) => s.points && s.points[0] && s.points[0][1]).filter((v) => v != null);
if (!vals.length) {
return h('div', { class: 'metrics-now none' },
h('span', { class: 'metrics-now-label', text: 'Now' }),
h('span', { text: 'no data — the alert condition no longer holds' }));
}
return h('div', { class: 'metrics-now' },
h('span', { class: 'metrics-now-label', text: 'Now' }),
h('strong', { text: vals.length === 1 ? fmtValue(vals[0]) : vals.map(fmtValue).join(' · ') }));
}
function legend(series) {
if (series.length < 2 && !(series[0] && Object.keys(series[0].labels || {}).length)) return null;
return h('ul', { class: 'metrics-legend' }, series.slice(0, MAX_SERIES).map((s, i) =>
h('li', {}, h('span', { class: `mc-swatch s${i}` }), h('span', { text: seriesName(s.labels) }))));
}
function chart(data, startSec, endSec) {
const W = 480;
const H = 130;
const padL = 40;
const padR = 8;
const padT = 8;
const padB = 18;
const series = data.series.slice(0, MAX_SERIES);
let t0 = Infinity; let t1 = -Infinity; let lo = Infinity; let hi = -Infinity;
for (const s of series) {
for (const [t, v] of s.points) {
if (t < t0) t0 = t;
if (t > t1) t1 = t;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
}
if (!Number.isFinite(t0)) { t0 = data.from; t1 = data.to; lo = 0; hi = 1; }
// Show the whole window, not just where there are samples, so the shape of
// "it only exists while firing" is visible.
t0 = Math.min(t0, data.from);
t1 = Math.max(t1, data.to);
if (t1 === t0) t1 = t0 + 1;
if (lo === hi) { lo -= 1; hi += 1; }
if (lo > 0 && lo < (hi - lo) * 0.5) lo = 0;
const x = (t) => padL + ((t - t0) / (t1 - t0)) * (W - padL - padR);
const y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);
const root = svg('svg', {
class: 'metric-chart', viewBox: `0 0 ${W} ${H}`, role: 'img',
'aria-label': `Metric over time, ${fmtValue(lo)} to ${fmtValue(hi)}, from ${when(iso(t0))} to ${when(iso(t1))}`,
});
// Gridlines and y labels at the extremes and the middle.
for (const v of [lo, (lo + hi) / 2, hi]) {
root.appendChild(svg('line', { class: 'mc-grid', x1: padL, x2: W - padR, y1: y(v), y2: y(v) }));
root.appendChild(svg('text', { class: 'mc-label', x: padL - 4, y: y(v) + 3, 'text-anchor': 'end' }, fmtValue(v)));
}
root.appendChild(svg('text', { class: 'mc-label', x: padL, y: H - 4, 'text-anchor': 'start' }, when(iso(t0))));
root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1))));
const marker = (t, cls, text) => {
if (t == null || t < t0 || t > t1) return;
const m = svg('line', { class: `mc-marker ${cls}`, x1: x(t), x2: x(t), y1: padT, y2: H - padB });
m.appendChild(svg('title', {}, `${text} ${when(iso(t))}`));
root.appendChild(m);
};
marker(startSec, 'start', 'Alert started');
marker(endSec, 'end', 'Incident resolved');
series.forEach((s, i) => {
if (!s.points.length) return;
const d = s.points.map(([t, v], n) => `${n ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const line = svg('path', { class: `mc-line s${i}`, d });
line.appendChild(svg('title', {}, `${seriesName(s.labels)}: ${fmtValue(s.points[s.points.length - 1][1])} at ${when(iso(s.points[s.points.length - 1][0]))}`));
root.appendChild(line);
// A single sample has no line to draw; show it as a dot.
if (s.points.length === 1) {
root.appendChild(svg('circle', { class: `mc-dot s${i}`, cx: x(s.points[0][0]), cy: y(s.points[0][1]), r: 3 }));
}
});
return root;
}
+3 -2
View File
@@ -10,6 +10,7 @@ import * as api from './api.js';
import { h, clear, badge, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
import { myID, currentTeam } from './state.js';
import { withAvatar } from './avatar.js';
const view = () => document.getElementById('view-oncall');
@@ -96,7 +97,7 @@ function nowCard() {
return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
withAvatar(h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), n.user_id),
h('div', {},
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
until && h('div', { class: 'hero-until', text: until }))));
@@ -143,7 +144,7 @@ function weekCard() {
},
h('span', { class: 'strip-name', text: dayName.format(d) }),
h('span', { class: 'strip-date', text: String(d.getDate()) }),
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
(el => (e ? withAvatar(el, e.user_id) : el))(h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' })),
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
+8
View File
@@ -2,6 +2,7 @@
// queue that drives the badges.
import * as api from './api.js';
import { noteAvatars, clearAvatars } from './avatar.js';
export const state = {
me: null, // { user, has_password }
@@ -70,6 +71,7 @@ let usersAt = 0;
export async function users() {
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
usersCache = await api.users();
noteAvatars(usersCache);
usersAt = Date.now();
}
return usersCache;
@@ -91,4 +93,10 @@ export function reset() {
state.open = [];
state.teams = [];
usersCache = null;
clearAvatars();
}
// After a picture changes: the cached list carries the old avatar_version.
export function forgetUsers() {
usersCache = null;
}
+2 -1
View File
@@ -20,6 +20,7 @@
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { withAvatar } from './avatar.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -466,7 +467,7 @@ function weekSheet(monday, byDate) {
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
? withAvatar(h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }), e.user_id)
: h('span', { class: 'rota-chip none' }));
}));