Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3b2c2bea | |||
| 525947e809 | |||
| 536dbcaebe | |||
| 50e9ebcbc2 | |||
| 83837421e7 | |||
| c242058f6c | |||
| f344bb707b | |||
| 94c394e8f1 | |||
| 12535e0e9a | |||
| 178c723475 |
@@ -35,7 +35,7 @@ jobs:
|
|||||||
# Runs inside the toolchain image rather than installing Go per job. Note this puts
|
# 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 --
|
# 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.
|
# 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
|
# 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
|
# 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
|
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
# 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
|
# 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.
|
# 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
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -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 |
|
| [Deployment](./docs/deployment.md) | Docker, Helm chart, database, backups, the operator |
|
||||||
| [Configuration](./docs/configuration.md) | Environment variables and settings |
|
| [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 |
|
| [Alertmanager configuration](./docs/alertmanager.md) | Routes, keys, webhooks |
|
||||||
| [Alerts and incidents](./docs/incidents.md) | Correlation, lifecycle, on-call assignment |
|
| [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 |
|
| [Single sign-on](./docs/single-sign-on.md) | OIDC and the terminal device flow |
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.47.2
|
version: 0.50.0
|
||||||
appVersion: "v0.47.2"
|
appVersion: "v0.50.0"
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ spec:
|
|||||||
value: {{ .Values.trustedProxies | quote }}
|
value: {{ .Values.trustedProxies | quote }}
|
||||||
- name: TERDUT_OPERATOR_MODE
|
- name: TERDUT_OPERATOR_MODE
|
||||||
value: {{ .Values.operatorMode | quote }}
|
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 }}
|
{{- if .Values.oidc.enabled }}
|
||||||
- name: TERDUT_OIDC_ISSUER
|
- name: TERDUT_OIDC_ISSUER
|
||||||
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ passwordLogin: true
|
|||||||
# configuration from here on.
|
# configuration from here on.
|
||||||
operatorMode: false
|
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.
|
# Single sign-on through an OpenID Connect provider such as Authentik.
|
||||||
#
|
#
|
||||||
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func main() {
|
|||||||
|
|
||||||
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notifyCfg)
|
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notifyCfg)
|
||||||
go api.StartNotifier(ctx, database, notifyCfg)
|
go api.StartNotifier(ctx, database, notifyCfg)
|
||||||
|
go api.StartMetricsWorker(ctx, database, cfg)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
|
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
|
||||||
|
|||||||
@@ -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}` | Get single incident, with its alerts inline |
|
||||||
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
|
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
|
||||||
| `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological |
|
| `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) |
|
| `POST` | `/api/incidents/{id}/acknowledge` | Acknowledge (stamps authed user + time) |
|
||||||
| `DELETE` | `/api/incidents/{id}/acknowledge` | Clear acknowledgement, back to `triggered` |
|
| `DELETE` | `/api/incidents/{id}/acknowledge` | Clear acknowledgement, back to `triggered` |
|
||||||
| `POST` | `/api/incidents/{id}/resolve` | Close by hand — **terminal**, see above |
|
| `POST` | `/api/incidents/{id}/resolve` | Close by hand — **terminal**, see above |
|
||||||
|
|||||||
@@ -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_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_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_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_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_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 |
|
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
|
||||||
|
|||||||
@@ -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]`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeadmanConfig is a test fixture only: a set of matchers with one timeout and
|
// 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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
|||||||
signupLimiter := newLoginLimiter(db)
|
signupLimiter := newLoginLimiter(db)
|
||||||
oidcLimit := newLoginLimiter(db)
|
oidcLimit := newLoginLimiter(db)
|
||||||
|
|
||||||
|
metrics := newMetricsService(cfg)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(requestLogger)
|
r.Use(requestLogger)
|
||||||
r.Use(middleware.Recoverer)
|
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.
|
// set up" that the person holding the phone can confirm.
|
||||||
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
|
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
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.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(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/clusters", handleListClusters(db))
|
||||||
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||||
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(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.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||||
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||||
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
caller, _ := userFromContext(r.Context())
|
caller, _ := userFromContext(r.Context())
|
||||||
seeAll := caller.IsAdmin
|
seeAll := caller.IsAdmin
|
||||||
rows, err := db.QueryContext(r.Context(),
|
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 {
|
if err != nil {
|
||||||
serverError(w, r, err)
|
serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
var disabled *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)
|
serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
|||||||
var ts int64
|
var ts int64
|
||||||
var disabled *int64
|
var disabled *int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", 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 WHERE id = $1", id).
|
||||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/prom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MinOperatorKeyLength is the shortest TERDUT_OPERATOR_KEY accepted: it is a
|
// 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
|
// "where this server is plugged in" — it is a statement about who owns
|
||||||
// this install's configuration, not a per-request toggle.
|
// this install's configuration, not a per-request toggle.
|
||||||
OperatorMode bool
|
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
|
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||||
@@ -151,6 +167,10 @@ func Load() Config {
|
|||||||
OIDC: loadOIDC(),
|
OIDC: loadOIDC(),
|
||||||
|
|
||||||
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
|
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 {
|
if c.OperatorKey != "" && len(c.OperatorKey) < MinOperatorKeyLength {
|
||||||
return fmt.Errorf("TERDUT_OPERATOR_KEY must be at least %d characters", 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
|
o := c.OIDC
|
||||||
if !o.Enabled() {
|
if !o.Enabled() {
|
||||||
if c.DisablePasswordLogin {
|
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
|
||||||
|
);
|
||||||
@@ -19,6 +19,11 @@ type User struct {
|
|||||||
// every acknowledgement and timeline entry they made.
|
// every acknowledgement and timeline entry they made.
|
||||||
DisabledAt *time.Time `json:"disabled_at,omitempty"`
|
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.
|
// IsAdmin is the system administrator flag: managing users and API keys.
|
||||||
// Not omitempty — a client has to be able to tell "false" from "this server
|
// Not omitempty — a client has to be able to tell "false" from "this server
|
||||||
// is too old to have the field", and the web UI decides what to show from
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #f5f6f8;
|
--bg: #f5f6f8;
|
||||||
|
--dot: rgba(60, 70, 95, 0.16);
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #eff1f4;
|
--surface-2: #eff1f4;
|
||||||
--surface-hover: #f7f8fa;
|
--surface-hover: #f7f8fa;
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
|
--dot: rgba(148, 156, 180, 0.26);
|
||||||
--surface: #1a1e26;
|
--surface: #1a1e26;
|
||||||
--surface-2: #232834;
|
--surface-2: #232834;
|
||||||
--surface-hover: #20252e;
|
--surface-hover: #20252e;
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
|
--dot: rgba(148, 156, 180, 0.26);
|
||||||
--surface: #1a1e26;
|
--surface: #1a1e26;
|
||||||
--surface-2: #232834;
|
--surface-2: #232834;
|
||||||
--surface-hover: #20252e;
|
--surface-hover: #20252e;
|
||||||
@@ -163,6 +166,17 @@ body {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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; }
|
a { color: inherit; text-decoration: none; }
|
||||||
button, input, textarea, select { font: inherit; color: inherit; }
|
button, input, textarea, select { font: inherit; color: inherit; }
|
||||||
code { font-family: var(--mono); font-size: 0.9em; }
|
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-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 { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
|
||||||
.alert-item-foot a { color: var(--accent); font-weight: 600; }
|
.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
|
/* 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). */
|
touch screen (the bare triangle it replaces was a few pixels wide). */
|
||||||
details > summary {
|
details > summary {
|
||||||
@@ -748,6 +789,18 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
.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; }
|
.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
|
/* 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. */
|
is easier to spot there than this plain accent-coloured text. */
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Account: who you are signed in as, changing your password, signing out.
|
// Account: who you are signed in as, changing your password, signing out.
|
||||||
|
|
||||||
import * as api from './api.js';
|
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 { initial } from './format.js';
|
||||||
import { state } from './state.js';
|
import { state, forgetUsers } from './state.js';
|
||||||
import { signOut } from './app.js';
|
import { withAvatar, setAvatarVersion } from './avatar.js';
|
||||||
|
import { signOut, renderAccountNav } from './app.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-more');
|
const view = () => document.getElementById('view-more');
|
||||||
|
|
||||||
@@ -24,10 +25,11 @@ function render(providers) {
|
|||||||
const { user, has_password: hasPassword } = state.me;
|
const { user, has_password: hasPassword } = state.me;
|
||||||
clear(view(),
|
clear(view(),
|
||||||
h('div', { class: 'card account-card' },
|
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', {},
|
||||||
h('div', { class: 'account-name', text: user.username }),
|
h('div', { class: 'account-name', text: user.username }),
|
||||||
h('div', { class: 'account-email', text: user.email }))),
|
h('div', { class: 'account-email', text: user.email }),
|
||||||
|
avatarControls(user))),
|
||||||
|
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||||
notifyCard(user, providers),
|
notifyCard(user, providers),
|
||||||
@@ -299,3 +301,125 @@ function shortcuts() {
|
|||||||
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||||||
h('td', { text: v })))));
|
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');
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,30 @@ export const setPassword = (userID, password, currentPassword) =>
|
|||||||
// users
|
// users
|
||||||
export const users = () => call('GET', '/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
|
// 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.
|
// about anybody else, which is what the admin page's per-user view needs.
|
||||||
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
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 archive = (id) => call('POST', `/incidents/${id}/archive`);
|
||||||
export const unarchive = (id) => call('DELETE', `/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 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 similar = (id) => call('GET', `/incidents/${id}/similar`);
|
||||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import * as ui from './ui.js';
|
import * as ui from './ui.js';
|
||||||
import * as poll from './poll.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 queue from './queue.js';
|
||||||
import * as incident from './incident.js';
|
import * as incident from './incident.js';
|
||||||
import * as oncall from './oncall.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 adminteam from './adminteam.js';
|
||||||
import * as device from './device.js';
|
import * as device from './device.js';
|
||||||
import { initial } from './format.js';
|
import { initial } from './format.js';
|
||||||
|
import { withAvatar, setAvatarVersion } from './avatar.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
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,
|
// 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
|
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
|
||||||
// screen reader still hears "Account".
|
// screen reader still hears "Account".
|
||||||
function renderAccountNav() {
|
export function renderAccountNav() {
|
||||||
const name = state.me?.user?.username;
|
const name = state.me?.user?.username;
|
||||||
if (!name) return;
|
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-avatar').hidden = false;
|
||||||
$('nav-account-label').textContent = name;
|
$('nav-account-label').textContent = name;
|
||||||
}
|
}
|
||||||
@@ -488,6 +492,8 @@ function showApp() {
|
|||||||
$('login').hidden = true;
|
$('login').hidden = true;
|
||||||
$('app').hidden = false;
|
$('app').hidden = false;
|
||||||
render();
|
render();
|
||||||
|
// Who has a picture: the first paint shows initials, the list repaints it.
|
||||||
|
ui_users().then(render).catch(() => {});
|
||||||
poll.start(refresh);
|
poll.start(refresh);
|
||||||
poll.now();
|
poll.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
import {
|
import {
|
||||||
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
|
ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { myID, users } from './state.js';
|
import { myID, users } from './state.js';
|
||||||
|
import { withAvatar } from './avatar.js';
|
||||||
import { back } from './app.js';
|
import { back } from './app.js';
|
||||||
|
import { alertMetrics } from './metricchart.js';
|
||||||
|
|
||||||
const pane = () => document.getElementById('detail');
|
const pane = () => document.getElementById('detail');
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ let currentID = null;
|
|||||||
let inc = null;
|
let inc = null;
|
||||||
let events = [];
|
let events = [];
|
||||||
let similarList = [];
|
let similarList = [];
|
||||||
|
let metricsByAlert = {};
|
||||||
let error = null;
|
let error = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ export function show(id) {
|
|||||||
currentID = id;
|
currentID = id;
|
||||||
inc = null;
|
inc = null;
|
||||||
events = [];
|
events = [];
|
||||||
|
metricsByAlert = {};
|
||||||
error = null;
|
error = null;
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
renderPlaceholder();
|
renderPlaceholder();
|
||||||
@@ -41,13 +45,17 @@ export async function refresh() {
|
|||||||
try {
|
try {
|
||||||
// Similar incidents are a courtesy: an older server answers 404 and a
|
// Similar incidents are a courtesy: an older server answers 404 and a
|
||||||
// failure here must not hide the incident itself.
|
// failure here must not hide the incident itself.
|
||||||
const [i, t, sim] = await Promise.all([
|
// Metrics are a courtesy too: an older server has no such endpoint, and a
|
||||||
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
// 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;
|
if (id !== currentID) return;
|
||||||
inc = i;
|
inc = i;
|
||||||
events = t;
|
events = t;
|
||||||
similarList = sim;
|
similarList = sim;
|
||||||
|
metricsByAlert = {};
|
||||||
|
if (met && met.enabled) for (const m of met.alerts) metricsByAlert[m.alert_id] = m;
|
||||||
error = null;
|
error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (id !== currentID) return;
|
if (id !== currentID) return;
|
||||||
@@ -203,6 +211,7 @@ function alertItem(a) {
|
|||||||
h('span', { class: 'alert-item-name', text: a.name }),
|
h('span', { class: 'alert-item-name', text: a.name }),
|
||||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
|
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
|
||||||
summary && h('div', { class: 'alert-item-summary', text: summary }),
|
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('div', { class: 'alert-item-foot' },
|
||||||
h('span', { text: `Started ${ago(a.starts_at)}` }),
|
h('span', { text: `Started ${ago(a.starts_at)}` }),
|
||||||
h('span', { text: `Last seen ${ago(a.received_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),
|
'aria-checked': String(u.id === inc.assigned_to_id),
|
||||||
onclick: () => closeSheet(u.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,
|
u.id === me ? `${u.username} (you)` : u.username,
|
||||||
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
|
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
|
||||||
)))),
|
)))),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import * as api from './api.js';
|
|||||||
import { h, clear, badge, icon, spinner } from './ui.js';
|
import { h, clear, badge, icon, spinner } from './ui.js';
|
||||||
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
||||||
import { myID, currentTeam } from './state.js';
|
import { myID, currentTeam } from './state.js';
|
||||||
|
import { withAvatar } from './avatar.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-oncall');
|
const view = () => document.getElementById('view-oncall');
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ function nowCard() {
|
|||||||
return h('div', { class: 'card hero' },
|
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-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||||
h('div', { class: 'hero-top' },
|
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', {},
|
||||||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||||
until && h('div', { class: 'hero-until', text: until }))));
|
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-name', text: dayName.format(d) }),
|
||||||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
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' })));
|
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()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// queue that drives the badges.
|
// queue that drives the badges.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
|
import { noteAvatars, clearAvatars } from './avatar.js';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
me: null, // { user, has_password }
|
me: null, // { user, has_password }
|
||||||
@@ -70,6 +71,7 @@ let usersAt = 0;
|
|||||||
export async function users() {
|
export async function users() {
|
||||||
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
|
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
|
||||||
usersCache = await api.users();
|
usersCache = await api.users();
|
||||||
|
noteAvatars(usersCache);
|
||||||
usersAt = Date.now();
|
usersAt = Date.now();
|
||||||
}
|
}
|
||||||
return usersCache;
|
return usersCache;
|
||||||
@@ -91,4 +93,10 @@ export function reset() {
|
|||||||
state.open = [];
|
state.open = [];
|
||||||
state.teams = [];
|
state.teams = [];
|
||||||
usersCache = null;
|
usersCache = null;
|
||||||
|
clearAvatars();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a picture changes: the cached list carries the old avatar_version.
|
||||||
|
export function forgetUsers() {
|
||||||
|
usersCache = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import * as api from './api.js';
|
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 { 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 { 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';
|
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
const view = () => document.getElementById('view-team');
|
||||||
@@ -466,7 +467,7 @@ function weekSheet(monday, byDate) {
|
|||||||
},
|
},
|
||||||
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
|
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
|
||||||
e
|
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' }));
|
: h('span', { class: 'rota-chip none' }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user