Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c242058f6c | |||
| f344bb707b | |||
| 94c394e8f1 | |||
| 12535e0e9a | |||
| 178c723475 | |||
| 8b9adb4910 | |||
| 220eb97665 | |||
| 8521bdcef7 |
@@ -35,7 +35,7 @@ jobs:
|
||||
# Runs inside the toolchain image rather than installing Go per job. Note this puts
|
||||
# the job on the dind bridge, which cannot reach github.com or get.helm.sh --
|
||||
# proxy.golang.org and git.ryuvia.com are reachable, which is all this job needs.
|
||||
image: golang:1.26.9-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
# act_runner destroys a job's own volumes when it finishes, so without these every
|
||||
# run re-downloads the whole module graph. The names must appear in the runner's
|
||||
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.26.9-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
volumes:
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.26.9-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
volumes:
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.26.9-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
volumes:
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
||||
# JS action that used to install it cannot run there), so this is not just an
|
||||
# optimisation -- it is what makes the arm64 image buildable at all.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.10-alpine AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -92,6 +92,7 @@ The [documentation index](./docs/README.md) lists everything. The main pages:
|
||||
|---|---|
|
||||
| [Deployment](./docs/deployment.md) | Docker, Helm chart, database, backups, the operator |
|
||||
| [Configuration](./docs/configuration.md) | Environment variables and settings |
|
||||
| [Metrics](./docs/metrics.md) | Graphs from the alert's Prometheus on the incident page |
|
||||
| [Alertmanager configuration](./docs/alertmanager.md) | Routes, keys, webhooks |
|
||||
| [Alerts and incidents](./docs/incidents.md) | Correlation, lifecycle, on-call assignment |
|
||||
| [Single sign-on](./docs/single-sign-on.md) | OIDC and the terminal device flow |
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.47.0
|
||||
appVersion: "v0.47.0"
|
||||
version: 0.49.0
|
||||
appVersion: "v0.49.0"
|
||||
|
||||
@@ -120,6 +120,14 @@ spec:
|
||||
value: {{ .Values.trustedProxies | quote }}
|
||||
- name: TERDUT_OPERATOR_MODE
|
||||
value: {{ .Values.operatorMode | quote }}
|
||||
{{- if .Values.prometheus.allowedHosts }}
|
||||
- name: TERDUT_PROMETHEUS_ALLOWED_HOSTS
|
||||
value: {{ join "," .Values.prometheus.allowedHosts | quote }}
|
||||
- name: TERDUT_PROMETHEUS_PREROLL
|
||||
value: {{ .Values.prometheus.preroll | quote }}
|
||||
- name: TERDUT_PROMETHEUS_TIMEOUT
|
||||
value: {{ .Values.prometheus.timeout | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.oidc.enabled }}
|
||||
- name: TERDUT_OIDC_ISSUER
|
||||
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
||||
|
||||
@@ -114,6 +114,18 @@ passwordLogin: true
|
||||
# configuration from here on.
|
||||
operatorMode: false
|
||||
|
||||
# Metrics on incidents: the server queries the Prometheus an alert came from
|
||||
# (the host in its generatorURL) and graphs the alert's expression. The URL is
|
||||
# taken from a webhook payload, so only hosts listed here are ever contacted;
|
||||
# empty turns the feature off. Entries are host names, with "*" as one DNS label
|
||||
# and a leading "**" as one or more (e.g. "*.monitoring.svc.cluster.local",
|
||||
# "prometheus-*.monitoring"), IPs or CIDR ranges, each with an optional ":port".
|
||||
prometheus:
|
||||
allowedHosts: []
|
||||
# How far before an alert started its graph begins.
|
||||
preroll: 1h
|
||||
timeout: 10s
|
||||
|
||||
# Single sign-on through an OpenID Connect provider such as Authentik.
|
||||
#
|
||||
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
||||
|
||||
@@ -64,6 +64,7 @@ func main() {
|
||||
|
||||
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notifyCfg)
|
||||
go api.StartNotifier(ctx, database, notifyCfg)
|
||||
go api.StartMetricsWorker(ctx, database, cfg)
|
||||
|
||||
go func() {
|
||||
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
|
||||
|
||||
@@ -236,6 +236,7 @@ the team gets the same `404` as anybody else.
|
||||
| `GET` | `/api/incidents/{id}` | Get single incident, with its alerts inline |
|
||||
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
|
||||
| `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological |
|
||||
| `GET` | `/api/incidents/{id}/metrics` | Metrics behind the incident's alerts, from their Prometheus — see [Metrics](./metrics.md) |
|
||||
| `POST` | `/api/incidents/{id}/acknowledge` | Acknowledge (stamps authed user + time) |
|
||||
| `DELETE` | `/api/incidents/{id}/acknowledge` | Clear acknowledgement, back to `triggered` |
|
||||
| `POST` | `/api/incidents/{id}/resolve` | Close by hand — **terminal**, see above |
|
||||
|
||||
@@ -30,6 +30,9 @@ over an administrator's edit.
|
||||
| `TERDUT_TRUSTED_PROXIES` | `1` | How many reverse proxies in front of the server append to `X-Forwarded-For`; the per-address rate limits use the entry that many hops from the right. `0` ignores the header |
|
||||
| `TERDUT_OPERATOR_KEY` | — | At least 32 characters. When set, the instance-scoped service account `terdut-operator` is created if missing and its `seed` key replaced with this value at every start — how terdut-operator authenticates without a bootstrap handshake. An instance-scoped account acts as owner of every team (team configuration) but is not a member of any, so it reads no incidents |
|
||||
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](./api.md#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
|
||||
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
|
||||
| `TERDUT_PROMETHEUS_PREROLL` | `1h` | How far before an alert started its metrics graph begins |
|
||||
| `TERDUT_PROMETHEUS_TIMEOUT` | `10s` | Per-request timeout for a Prometheus query |
|
||||
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](./single-sign-on.md#single-sign-on-oidc) |
|
||||
| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values |
|
||||
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
|
||||
|
||||
@@ -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]`.
|
||||
@@ -1,8 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// DeadmanConfig is a test fixture only: a set of matchers with one timeout and
|
||||
@@ -29,3 +33,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// MetricsSweep runs one pass of the metrics worker, so a test need not wait on
|
||||
// its ticker.
|
||||
func MetricsSweep(ctx context.Context, db *sql.DB, cfg config.Config) {
|
||||
newMetricsService(cfg).sweep(ctx, db)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
oidcLimit := newLoginLimiter(db)
|
||||
|
||||
metrics := newMetricsService(cfg)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLogger)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -149,6 +151,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
r.Get("/api/incidents/clusters", handleListClusters(db))
|
||||
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
||||
r.Get("/api/incidents/{id}/metrics", handleIncidentMetrics(db, metrics))
|
||||
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||
|
||||
@@ -490,3 +490,31 @@ func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
|
||||
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// The operator reconciles a team's dead man's switches by listing them, and its
|
||||
// instance-scoped account is not a member of any team it manages.
|
||||
func TestServiceAccount_InstanceScopeListsATeamsDeadmanSwitches(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
team := createTeamAs(t, s, instanceKey, "team-a")
|
||||
path := "/api/teams/" + id64(team) + "/deadman/switches"
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodPost, path,
|
||||
map[string]any{"name": "watchdog", "matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create switch: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp = s.reqAs(t, instanceKey, http.MethodGet, path, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list switches as the instance account: %d", resp.StatusCode)
|
||||
}
|
||||
var list []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
decode(t, resp, &list)
|
||||
if len(list) != 1 || list[0].Name != "watchdog" {
|
||||
t.Errorf("expected the one switch just created, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +826,10 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamMember(w, r, teamID) {
|
||||
// The operator's instance-scoped account reconciles the switches by name,
|
||||
// so it has to read them without being a member: that is owner reach,
|
||||
// the same as the writes below, and still reads no incidents.
|
||||
if !isInstanceServiceAccount(r.Context()) && !requireTeamMember(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/prom"
|
||||
)
|
||||
|
||||
// MinOperatorKeyLength is the shortest TERDUT_OPERATOR_KEY accepted: it is a
|
||||
@@ -79,6 +81,20 @@ type Config struct {
|
||||
// "where this server is plugged in" — it is a statement about who owns
|
||||
// this install's configuration, not a per-request toggle.
|
||||
OperatorMode bool
|
||||
|
||||
// PrometheusAllowedHosts lists the Prometheus servers this install may query
|
||||
// for the metrics behind an alert, matched against the host in each alert's
|
||||
// generatorURL. Entries are host names (with "*" and a leading "**" as
|
||||
// wildcards), IPs or CIDR ranges, optionally with a port; see prom.Allowlist.
|
||||
// Empty turns the feature off, which is the default: the URL comes from a
|
||||
// webhook payload, so querying it is opt-in.
|
||||
PrometheusAllowedHosts []string
|
||||
|
||||
// PrometheusPreroll is how far before an alert started its graph begins.
|
||||
PrometheusPreroll time.Duration
|
||||
|
||||
// PrometheusTimeout bounds one request to a Prometheus.
|
||||
PrometheusTimeout time.Duration
|
||||
}
|
||||
|
||||
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||
@@ -151,6 +167,10 @@ func Load() Config {
|
||||
OIDC: loadOIDC(),
|
||||
|
||||
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
|
||||
|
||||
PrometheusAllowedHosts: list("TERDUT_PROMETHEUS_ALLOWED_HOSTS", ""),
|
||||
PrometheusPreroll: duration("TERDUT_PROMETHEUS_PREROLL", time.Hour),
|
||||
PrometheusTimeout: duration("TERDUT_PROMETHEUS_TIMEOUT", 10*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +200,9 @@ func (c Config) Validate() error {
|
||||
if c.OperatorKey != "" && len(c.OperatorKey) < MinOperatorKeyLength {
|
||||
return fmt.Errorf("TERDUT_OPERATOR_KEY must be at least %d characters", MinOperatorKeyLength)
|
||||
}
|
||||
if _, err := prom.ParseAllowlist(c.PrometheusAllowedHosts); err != nil {
|
||||
return fmt.Errorf("TERDUT_PROMETHEUS_ALLOWED_HOSTS: %w", err)
|
||||
}
|
||||
o := c.OIDC
|
||||
if !o.Enabled() {
|
||||
if c.DisablePasswordLogin {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Metrics captured from the Prometheus an alert came from (its generatorURL),
|
||||
-- once when the incident opens and once when it closes, so the graph outlives
|
||||
-- Prometheus' own retention. One row per incident, alert and kind.
|
||||
--
|
||||
-- A row exists whether or not the capture worked: a failure is recorded in
|
||||
-- error with the attempt count, which is what stops the worker from retrying a
|
||||
-- host that is not on the allowlist (or is gone) forever.
|
||||
|
||||
CREATE TABLE incident_metric_snapshots (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
incident_id bigint NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
alert_id bigint NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('opened', 'closed')),
|
||||
expr text NOT NULL DEFAULT '',
|
||||
window_start bigint NOT NULL,
|
||||
window_end bigint NOT NULL,
|
||||
step_seconds integer NOT NULL DEFAULT 0,
|
||||
series jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
error text,
|
||||
attempts integer NOT NULL DEFAULT 1,
|
||||
captured_at bigint NOT NULL,
|
||||
UNIQUE (incident_id, alert_id, kind)
|
||||
);
|
||||
|
||||
CREATE INDEX incident_metric_snapshots_alert_id_idx ON incident_metric_snapshots (alert_id);
|
||||
@@ -0,0 +1,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 {
|
||||
--bg: #f5f6f8;
|
||||
--dot: rgba(60, 70, 95, 0.16);
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eff1f4;
|
||||
--surface-hover: #f7f8fa;
|
||||
@@ -79,6 +80,7 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0f1115;
|
||||
--dot: rgba(148, 156, 180, 0.26);
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
@@ -114,6 +116,7 @@
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115;
|
||||
--dot: rgba(148, 156, 180, 0.26);
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
@@ -163,6 +166,17 @@ body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Dot grid behind everything, fading out down the screen. z-index -1 sits above
|
||||
the canvas background but below all in-flow content, so no wrapper element
|
||||
is needed. */
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
||||
background-image: radial-gradient(circle, var(--dot) 1.25px, transparent 1.75px);
|
||||
background-size: 28px 28px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
|
||||
mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select { font: inherit; color: inherit; }
|
||||
code { font-family: var(--mono); font-size: 0.9em; }
|
||||
@@ -586,6 +600,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
|
||||
.alert-item-foot a { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Metrics under an alert */
|
||||
.metrics { display: grid; gap: 6px; }
|
||||
.metrics-note { color: var(--faint); font-size: 13px; }
|
||||
.metrics-now { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
|
||||
.metrics-now strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.metrics-now.none { color: var(--muted); }
|
||||
.metrics-now-label { color: var(--faint); font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.metric-chart { width: 100%; height: auto; display: block; }
|
||||
.mc-grid { stroke: var(--border); stroke-width: 1; }
|
||||
.mc-label { fill: var(--faint); font-size: 10px; }
|
||||
.mc-marker { stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
.mc-marker.start { stroke: var(--crit); }
|
||||
.mc-marker.end { stroke: var(--ok); }
|
||||
.mc-line { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.mc-line.s0, .mc-swatch.s0 { stroke: var(--accent); background: var(--accent); }
|
||||
.mc-line.s1, .mc-swatch.s1 { stroke: var(--warn); background: var(--warn); }
|
||||
.mc-line.s2, .mc-swatch.s2 { stroke: var(--ok); background: var(--ok); }
|
||||
.mc-line.s3, .mc-swatch.s3 { stroke: var(--crit); background: var(--crit); }
|
||||
.mc-line.s4, .mc-swatch.s4 { stroke: var(--muted); background: var(--muted); }
|
||||
.mc-dot.s0 { fill: var(--accent); } .mc-dot.s1 { fill: var(--warn); } .mc-dot.s2 { fill: var(--ok); }
|
||||
.mc-dot.s3 { fill: var(--crit); } .mc-dot.s4 { fill: var(--muted); }
|
||||
.mc-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; }
|
||||
.metrics-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; color: var(--muted); }
|
||||
.metrics-legend li { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
|
||||
.metrics-expr { font-size: 12px; color: var(--faint); overflow-wrap: anywhere; }
|
||||
|
||||
/* A disclosure is a button: a chevron that turns, and a 44px target on a
|
||||
touch screen (the bare triangle it replaces was a few pixels wide). */
|
||||
details > summary {
|
||||
@@ -1386,6 +1427,46 @@ a.top-row:hover { background: var(--surface-2); }
|
||||
.stats-split { grid-template-columns: 3fr 1fr; align-items: stretch; }
|
||||
}
|
||||
|
||||
/* Phones: nothing on the stats pages may be wider than the screen, so the
|
||||
wide rows fold into two or three lines instead of scrolling sideways. */
|
||||
.stats > *, .stats-split > * { min-width: 0; }
|
||||
@media (max-width: 599px) {
|
||||
.top-head { display: none; }
|
||||
.toplist { overflow-x: visible; }
|
||||
.top-row {
|
||||
min-width: 0; grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; padding: 10px 4px;
|
||||
}
|
||||
.top-row > :nth-child(1) { grid-column: 1; grid-row: 1; font-weight: 600; }
|
||||
.top-row > :nth-child(6) { grid-column: 2; grid-row: 1; }
|
||||
.top-row > :nth-child(2) { grid-column: 1; grid-row: 2; }
|
||||
.top-row > :nth-child(3) { grid-column: 2; grid-row: 2; }
|
||||
.top-row > :nth-child(4) { grid-column: 1; grid-row: 3; font-size: 12px; }
|
||||
.top-row > :nth-child(5) { grid-column: 2; grid-row: 3; font-size: 12px; text-align: right; }
|
||||
.top-row > :nth-child(4)::before { content: "Last seen "; }
|
||||
.top-row > :nth-child(5)::before { content: "Fix "; }
|
||||
|
||||
.heatmap > * { min-width: 0; }
|
||||
.heat-row, .heat-cells { gap: 2px; }
|
||||
.heat-day { width: 26px; }
|
||||
.heat-cell { height: 18px; border-radius: 3px; }
|
||||
.heat-hour { font-size: 9px; overflow: visible; white-space: nowrap; }
|
||||
.heat-hour:not(:nth-child(6n+1)) { visibility: hidden; }
|
||||
|
||||
.fix-row { flex-wrap: wrap; row-gap: 2px; }
|
||||
.fix-label { width: auto; flex: 1; }
|
||||
.fix-total { width: auto; }
|
||||
.fix-track { flex: 1 1 100%; order: 3; }
|
||||
|
||||
.occ-wrap { overflow-x: visible; }
|
||||
.occ { min-width: 0; }
|
||||
.occ thead { display: none; }
|
||||
.occ, .occ tbody { display: block; }
|
||||
.occ tr { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; padding: 10px 0; border-top: 1px solid var(--border); }
|
||||
.occ td { border-top: 0; padding: 0; overflow-wrap: anywhere; }
|
||||
.occ td:first-child { grid-column: 1 / -1; font-weight: 600; }
|
||||
.occ td[data-label]::before { content: attr(data-label) ": "; color: var(--muted); font-size: 12px; }
|
||||
}
|
||||
|
||||
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||
@media (min-width: 900px) {
|
||||
|
||||
@@ -108,6 +108,7 @@ export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
||||
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
||||
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
||||
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
|
||||
export const metrics = (id) => call('GET', `/incidents/${id}/metrics`);
|
||||
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
|
||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './format.js';
|
||||
import { myID, users } from './state.js';
|
||||
import { back } from './app.js';
|
||||
import { alertMetrics } from './metricchart.js';
|
||||
|
||||
const pane = () => document.getElementById('detail');
|
||||
|
||||
@@ -18,6 +19,7 @@ let currentID = null;
|
||||
let inc = null;
|
||||
let events = [];
|
||||
let similarList = [];
|
||||
let metricsByAlert = {};
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
@@ -26,6 +28,7 @@ export function show(id) {
|
||||
currentID = id;
|
||||
inc = null;
|
||||
events = [];
|
||||
metricsByAlert = {};
|
||||
error = null;
|
||||
if (id == null) {
|
||||
renderPlaceholder();
|
||||
@@ -41,13 +44,17 @@ export async function refresh() {
|
||||
try {
|
||||
// Similar incidents are a courtesy: an older server answers 404 and a
|
||||
// failure here must not hide the incident itself.
|
||||
const [i, t, sim] = await Promise.all([
|
||||
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
||||
// Metrics are a courtesy too: an older server has no such endpoint, and a
|
||||
// Prometheus that is slow or down must not hold up the incident.
|
||||
const [i, t, sim, met] = await Promise.all([
|
||||
api.incident(id), api.timeline(id), api.similar(id).catch(() => []), api.metrics(id).catch(() => null),
|
||||
]);
|
||||
if (id !== currentID) return;
|
||||
inc = i;
|
||||
events = t;
|
||||
similarList = sim;
|
||||
metricsByAlert = {};
|
||||
if (met && met.enabled) for (const m of met.alerts) metricsByAlert[m.alert_id] = m;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
if (id !== currentID) return;
|
||||
@@ -203,6 +210,7 @@ function alertItem(a) {
|
||||
h('span', { class: 'alert-item-name', text: a.name }),
|
||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
|
||||
summary && h('div', { class: 'alert-item-summary', text: summary }),
|
||||
alertMetrics(metricsByAlert[a.id], a.starts_at, inc.resolved_at),
|
||||
h('div', { class: 'alert-item-foot' },
|
||||
h('span', { text: `Started ${ago(a.starts_at)}` }),
|
||||
h('span', { text: `Last seen ${ago(a.received_at)}` }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -103,17 +103,20 @@ function fixBars(d) {
|
||||
}));
|
||||
}
|
||||
|
||||
// On a phone each row folds into a card; the labels come from data-label.
|
||||
const OCC_COLS = ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'];
|
||||
|
||||
function table(d) {
|
||||
return h('div', { class: 'occ-wrap' },
|
||||
h('table', { class: 'occ' },
|
||||
h('thead', {}, h('tr', {}, ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'].map((t) => h('th', { text: t })))),
|
||||
h('thead', {}, h('tr', {}, OCC_COLS.map((t) => h('th', { text: t })))),
|
||||
h('tbody', {}, d.occurrences.map((o) => h('tr', {},
|
||||
h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }),
|
||||
` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`),
|
||||
h('td', { text: sec(o.ack_seconds) }),
|
||||
h('td', { text: sec(o.resolve_seconds) }),
|
||||
h('td', { text: o.acknowledged_by || '—' }),
|
||||
h('td', { text: o.status }))))));
|
||||
h('td', { 'data-label': OCC_COLS[1], text: sec(o.ack_seconds) }),
|
||||
h('td', { 'data-label': OCC_COLS[2], text: sec(o.resolve_seconds) }),
|
||||
h('td', { 'data-label': OCC_COLS[3], text: o.acknowledged_by || '—' }),
|
||||
h('td', { 'data-label': OCC_COLS[4], text: o.status }))))));
|
||||
}
|
||||
|
||||
export function body(name, d, error, onRange) {
|
||||
|
||||
Reference in New Issue
Block a user