Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db474ca909 | |||
| a23e88c16d | |||
| def0f68d00 | |||
| fb86a18988 | |||
| b848143471 | |||
| 942517c7a8 | |||
| 7665e5e52f | |||
| bcf1a3e99b | |||
| 3a96b20cbe | |||
| 7b9d309d13 | |||
| 7412456c5a | |||
| 065b557860 | |||
| a8dc89e23d | |||
| ead5df1574 | |||
| 3ced069134 | |||
| 0f88574a41 | |||
| 3613fd5732 | |||
| dc62278788 | |||
| 0aaea8efb5 | |||
| 584d3441fc | |||
| 926aa2d3ec | |||
| a2ca9c25d0 | |||
| 92959cac38 | |||
| f15db0e20a | |||
| b82c10acf4 | |||
| 7cd6fbf571 | |||
| df83adfe47 | |||
| 9da913080f | |||
| 2b2609e98f | |||
| fa6d82d6e5 | |||
| 7efd1bbba7 | |||
| 3bf94a5d7f | |||
| 5c4e0bdd0e | |||
| 9e5b085d8b | |||
| 0050738ca0 | |||
| 42180948d1 | |||
| c83c7c2a8b | |||
| 43beda9a30 | |||
| 710521a73c | |||
| d9492913ed | |||
| 1770e5d945 |
@@ -125,6 +125,9 @@ jobs:
|
||||
- name: Secret scan (gitleaks)
|
||||
run: make security-secrets
|
||||
|
||||
- name: Code security scan (gosec)
|
||||
run: make security-code
|
||||
|
||||
# Host mode, no `container:`: helm is baked into the runner image, and a container job
|
||||
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
|
||||
# release.yaml's chart job runs on the host.
|
||||
|
||||
@@ -24,4 +24,10 @@ FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /terdut /terdut
|
||||
EXPOSE 8080
|
||||
# Numeric, not a name: scratch has no /etc/passwd for one to resolve against,
|
||||
# and Docker's USER accepts a bare UID:GID without it. 65532 is the common
|
||||
# "nonroot" convention (distroless's own uid), chosen so the chart's pod
|
||||
# securityContext (runAsNonRoot, runAsUser: 65532) matches what the image
|
||||
# already runs as rather than fighting it.
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/terdut"]
|
||||
|
||||
@@ -143,6 +143,7 @@ BUILDX_BUILDER ?= terdut
|
||||
TRIVY_VERSION := 0.73.0
|
||||
GOVULNCHECK_VERSION := v1.1.4
|
||||
GITLEAKS_VERSION := v8.30.0
|
||||
GOSEC_VERSION := v2.29.0
|
||||
|
||||
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
|
||||
DOCKER_BUILD_FLAGS ?= --pull
|
||||
@@ -222,6 +223,26 @@ release: push helm-package helm-push ## Publish image + chart (the workflow's on
|
||||
security-go: ## Scan Go deps for known CVEs (govulncheck)
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
# Code-level, not dependency- or secret-level: gosec reads this repo's own source for
|
||||
# known-dangerous patterns (weak crypto, SQL/command injection shapes, insecure file
|
||||
# permissions, …) rather than its module graph or working tree for leaked credentials,
|
||||
# which is what security-go and security-secrets above already cover.
|
||||
#
|
||||
# G104 (unchecked error) is excluded. Every hit it found here on first run was this
|
||||
# codebase's existing, deliberate idiom for a best-effort write or an already-reviewed
|
||||
# json.Unmarshal of this server's own JSONB (see the "best-effort" comments in
|
||||
# middleware.go and the //nolint:errcheck lines in alerts.go/deadman.go) -- a style that
|
||||
# predates gosec and that G104 cannot distinguish from a mistake. Reaching the same
|
||||
# green result by adding a dozens of individual #nosec comments would not add
|
||||
# information; it would just make a future *real* G104 regression one more suppressed
|
||||
# line instead of a visible one. Same reasoning as the chi-advisories note on
|
||||
# security-go above: what gosec reports here (nothing, beyond G104) is the useful
|
||||
# property, not a loophole. -exclude-generated skips web.go's embedded, build-time-only
|
||||
# assets.
|
||||
.PHONY: security-code
|
||||
security-code: ## Scan this repo's own source for risky patterns (gosec)
|
||||
go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -exclude-generated -exclude=G104 ./...
|
||||
|
||||
# --no-git scans the working tree rather than the history, so this catches a secret on the
|
||||
# way in. It is not a history audit and finding nothing here says nothing about what is
|
||||
# already committed. --redact because the finding is printed into a CI log.
|
||||
|
||||
@@ -444,6 +444,25 @@ high-water mark — the highest `severity` label any of its alerts has carried
|
||||
an incident that hit `critical` still reads as critical after the critical alert
|
||||
clears.
|
||||
|
||||
### Several clusters, one team
|
||||
|
||||
A team with one Alertmanager per Kubernetes cluster, each posting to its own
|
||||
source, needs two settings or the clusters run together.
|
||||
|
||||
1. Give every alert a `cluster` label at the source. In Prometheus that is
|
||||
`externalLabels: {cluster: prod-eu}` (kube-prometheus-stack:
|
||||
`prometheus.prometheusSpec.externalLabels`).
|
||||
2. Add `cluster` to `group_by` in `alertmanager.yml`.
|
||||
|
||||
The second one is the one that matters. Incidents are matched on the team and
|
||||
Alertmanager's `groupKey`, and the `groupKey` does not include external labels:
|
||||
without `cluster` in `group_by`, the same alert in two clusters has the same
|
||||
key and joins one incident. With it, each cluster gets its own, `cluster` is in
|
||||
the incident's `group_labels`, and the web UI shows it as a coloured chip on the
|
||||
queue, the incident and the alert list, instead of leaving it in the title.
|
||||
An alert that is not grouped by `cluster` still shows the chip on the alert
|
||||
list, which reads the label from the alert itself.
|
||||
|
||||
### An incident opens only on a new occurrence
|
||||
|
||||
An incident opens when an alert **transitions into firing**: a fingerprint that
|
||||
@@ -984,9 +1003,11 @@ name: degrade unknown values to "resolved, reason unknown".
|
||||
| `created_at` | timestamp | |
|
||||
|
||||
Types written today: `triggered`, `alert_added`, `alert_resolved`,
|
||||
`acknowledged`, `unacknowledged`, `assigned`, `snoozed`, `unsnoozed`, `resolved`,
|
||||
`note`, `notified`, `notify_failed`, `deadman_silent`. On an `assigned` event
|
||||
`user_id` is the **assignee**, not the actor. New types may be added; render
|
||||
`acknowledged`, `unacknowledged`, `assigned`, `archived`, `unarchived`, `snoozed`,
|
||||
`unsnoozed`, `resolved`, `note`, `notified`, `notify_failed`, `deadman_silent`. On an
|
||||
`assigned` event `user_id` is the **assignee**, not the actor; the actor is in
|
||||
`actor_user_id`/`actor_username` or `actor_service_account_id`/`actor_service_account_name`
|
||||
(absent on assignments made before they were recorded). New types may be added; render
|
||||
unknown ones generically rather than dropping them.
|
||||
|
||||
On `notified` and `notify_failed`, `detail` carries the notification kind
|
||||
|
||||
@@ -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.35.0
|
||||
appVersion: "v0.35.0"
|
||||
version: 0.42.1
|
||||
appVersion: "v0.42.1"
|
||||
|
||||
@@ -6,26 +6,49 @@ metadata:
|
||||
labels:
|
||||
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
||||
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
|
||||
# sweeper and the notifier are unsynchronised singletons, and two replicas
|
||||
# overlapping during a rollout would both page for the same incident.
|
||||
# RollingUpdate, not Recreate: the sweeper, notifier and migration runner
|
||||
# each take a Postgres advisory lock around their own pass, and new-incident
|
||||
# creation on the first webhook for a brand-new groupKey resolves its own
|
||||
# insert conflict -- so two replicas overlapping during a rollout no longer
|
||||
# double-page, race a migration, or drop a webhook payload (v0.36.0). No
|
||||
# explicit maxUnavailable/maxSurge: the 25%/25% default rounds to 0/1 at
|
||||
# replicaCount: 2, which is zero-downtime already.
|
||||
strategy:
|
||||
type: Recreate
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
# Pod-wide default; both containers below run as this UID regardless of
|
||||
# what their own image would otherwise pick (postgres:17-alpine's
|
||||
# pg_isready needs no particular user, and 65532 is what the app image
|
||||
# itself runs as now — see the Dockerfile's USER). seccompProfile here
|
||||
# rather than per-container: there is no reason it would ever differ
|
||||
# between them.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
{{- if .Values.database.waitForPostgres.enabled }}
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
|
||||
# No capability this loop needs, and nothing in it writes to disk:
|
||||
# sh, pg_isready, echo and sleep all run read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: TERDUT_DB_DSN
|
||||
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
||||
@@ -42,6 +65,13 @@ spec:
|
||||
- name: terdut-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
# scratch, nothing to write: the binary keeps no local state and
|
||||
# writes nothing to disk, so the root filesystem can stay read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Safe above 1 since v0.36.0: the sweeper, notifier and migration runner each
|
||||
# take a Postgres advisory lock around their own pass, and a webhook that
|
||||
# loses the race to open a brand-new incident attaches to the winner's row
|
||||
# instead of dropping its payload. An image older than v0.36.0 does not have
|
||||
# these guards -- do not raise this against one.
|
||||
replicaCount: 2
|
||||
|
||||
networking:
|
||||
hostname: "terdut.example.com"
|
||||
servicePort: 8080
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
// This file is internal (package api, not api_test) because withAdvisoryLock is
|
||||
// unexported and these tests exercise its locking semantics directly rather than
|
||||
// through the full StartArchiver/StartNotifier loop, which would make the "does
|
||||
// not run while held" case timing-dependent instead of deterministic. It opens a
|
||||
// plain connection to TERDUT_TEST_DSN rather than reusing testdb_test.go's
|
||||
// newTestDB, since that helper lives in the separate, already-compiled
|
||||
// api_test package and a Postgres advisory lock needs no schema or migration
|
||||
// to exercise.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// advisoryTestDB opens a plain, unmigrated connection to the test database. An
|
||||
// unset DSN fails rather than skips, matching testdb_test.go's rationale: a
|
||||
// suite that quietly tests nothing is worse than one that does not run.
|
||||
func advisoryTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||
"Run `make test-db` for a local one, then\n" +
|
||||
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_RunsWhenFree(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
|
||||
if !ran {
|
||||
t.Fatal("fn did not run although the lock was free")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_SkipsWhileHeldElsewhere(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Hold the lock on a connection of our own, standing in for another
|
||||
// replica mid-pass.
|
||||
holder, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Close()
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("pre-acquire lock: %v", err)
|
||||
}
|
||||
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
if ran {
|
||||
t.Fatal("fn ran although another connection already held the lock")
|
||||
}
|
||||
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("release held lock: %v", err)
|
||||
}
|
||||
|
||||
// Now that the holder released it, the next caller should get it.
|
||||
ran = false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run after the other connection released the lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_ReleasesAfterFnReturns(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() {})
|
||||
|
||||
// If the first call had leaked the lock, this one would see it held and
|
||||
// skip, leaving ran false.
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run on a later call: the earlier call leaked its lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_KeysAreIndependent(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
holder, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Close()
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("pre-acquire archiver lock: %v", err)
|
||||
}
|
||||
defer holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey)
|
||||
|
||||
// Holding archiverLockKey must not block notifierLockKey.
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run under a different key although only archiverLockKey was held")
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,16 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
// maxWebhookBodyBytes is larger than maxBodyBytes: a real Alertmanager batch
|
||||
// can carry many alerts, each with several labels and annotations, and the
|
||||
// sender is a trusted piece of infrastructure rather than an arbitrary
|
||||
// caller.
|
||||
const maxWebhookBodyBytes = 8 << 20
|
||||
|
||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
|
||||
teamID := src.teamID
|
||||
var payload amPayload
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
if err := decodeJSONLimit(r, &payload, maxWebhookBodyBytes); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||
return
|
||||
}
|
||||
@@ -169,7 +175,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, src alertSourc
|
||||
}
|
||||
touched[id] = true
|
||||
alertID := a.id
|
||||
if err := logEvent(ctx, tx, id, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||
if err := logEvent(ctx, tx, id, evAlertResolved, nil, nil, &alertID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -376,6 +382,17 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, team
|
||||
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
||||
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||
// one because its incidents have no members to derive it from.
|
||||
//
|
||||
// Both callers get here only after their own SELECT found no open incident for
|
||||
// this group_key — but on more than one replica, two webhook deliveries for the
|
||||
// very first occurrence of a brand-new group_key can both pass that SELECT
|
||||
// before either INSERTs. ON CONFLICT DO NOTHING against
|
||||
// incidents_open_group_key_idx is what makes the loser's INSERT a no-op instead
|
||||
// of a unique-violation error that would otherwise roll back its entire
|
||||
// payload; existingOpenIncident then hands it the winner's row. Postgres
|
||||
// resolves that conflict only once the winner's transaction has committed (or
|
||||
// rolled back), so by the time this RETURNING comes back empty, the winner's
|
||||
// row is guaranteed visible to that follow-up SELECT.
|
||||
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||
onCall, err := currentOnCall(ctx, q, teamID)
|
||||
if err != nil {
|
||||
@@ -391,19 +408,27 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
||||
err = q.QueryRowContext(ctx, `
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||
ON CONFLICT (team_id, group_key) WHERE resolved_at IS NULL DO NOTHING
|
||||
RETURNING id`,
|
||||
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||
time.Now().Unix(), onCall).Scan(&id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
// Lost the race: someone else's incident for this group_key exists now.
|
||||
// Everything below — the trigger event, assignment, page, escalation
|
||||
// clock — already happened for that row when it was created; attach to
|
||||
// it rather than fail this call (and the whole payload) outright.
|
||||
return existingOpenIncident(ctx, q, teamID, groupKey)
|
||||
case err != nil:
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if onCall != nil {
|
||||
// On an "assigned" event user_id is the assignee, not the actor.
|
||||
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
@@ -423,6 +448,21 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// existingOpenIncident looks up the open incident openIncident's own INSERT just
|
||||
// lost a conflict against — the same lookup incidentForGroup does before ever
|
||||
// calling openIncident, repeated here for the caller that arrived second.
|
||||
func existingOpenIncident(ctx context.Context, q querier, teamID int64, groupKey string) (int64, error) {
|
||||
var id int64
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
|
||||
teamID, groupKey,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// linkAlert adds an alert to an incident, emitting a timeline entry only the
|
||||
// first time. Re-sends of an already-linked alert are silent.
|
||||
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
||||
@@ -436,5 +476,5 @@ func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil
|
||||
}
|
||||
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, &alertID, nil)
|
||||
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, nil, &alertID, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident reproduces two
|
||||
// replicas racing the very first webhook delivery for a brand-new group_key:
|
||||
// both see no open incident yet (incidentForGroup's own SELECT finds
|
||||
// nothing) and race openIncident's INSERT.
|
||||
//
|
||||
// The DB's own unique index already guarantees at most one incident either
|
||||
// way, with or without this fix — so "exactly one incident" alone cannot
|
||||
// tell a fixed run from a broken one. What ON CONFLICT handling actually
|
||||
// changes is what happens to the *loser*: before it, the loser's INSERT hit
|
||||
// incidents_open_group_key_idx's unique violation, which — since
|
||||
// upsertAlerts ran earlier in that same transaction — rolled back its whole
|
||||
// payload, alert insert included. ingest's error is only logged and
|
||||
// receiveWebhook answers 200 regardless, so nothing ever retried it: the
|
||||
// loser's alert silently never existed. That is the regression signal this
|
||||
// test checks — every caller's fingerprint must show up in /api/alerts, not
|
||||
// just the winner's.
|
||||
func TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
const callers = 8
|
||||
const groupKey = "race-group"
|
||||
|
||||
// Every caller needs its own fingerprint. A shared one would serialize all
|
||||
// of them at upsertAlerts' own ON CONFLICT (team_id, fingerprint) row lock,
|
||||
// long before any of them reached incidentForGroup — which would hide the
|
||||
// very race this test exists to force.
|
||||
bodies := make([][]byte, callers)
|
||||
for i := range callers {
|
||||
payload := map[string]any{
|
||||
"version": "4",
|
||||
"status": "firing",
|
||||
"groupKey": groupKey,
|
||||
"groupLabels": map[string]string{"alertname": "RaceAlert"},
|
||||
"alerts": []map[string]any{amAlert(fmt.Sprintf("fp-race-%d", i), "RaceAlert", "firing",
|
||||
"2026-05-20T10:00:00Z", "0001-01-01T00:00:00Z", nil)},
|
||||
}
|
||||
bodies[i], _ = json.Marshal(payload)
|
||||
}
|
||||
|
||||
// A start line, so every request is fired as close to simultaneously as
|
||||
// goroutine scheduling allows, rather than trickling out one dial at a
|
||||
// time — the race window is the gap between incidentForGroup's SELECT and
|
||||
// openIncident's INSERT, which a staggered start could easily miss.
|
||||
var ready sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
statuses := make([]int, callers)
|
||||
var wg sync.WaitGroup
|
||||
for i := range callers {
|
||||
ready.Add(1)
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ready.Done()
|
||||
<-start
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(bodies[i]))
|
||||
if err != nil {
|
||||
t.Errorf("post webhook #%d: %v", i, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
statuses[i] = resp.StatusCode
|
||||
}(i)
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, code := range statuses {
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("webhook #%d returned %d, want 200", i, code)
|
||||
}
|
||||
}
|
||||
|
||||
var matched []any
|
||||
for _, inc := range listIncidents(t, s, "") {
|
||||
if inc["group_key"] == groupKey {
|
||||
matched = append(matched, inc["id"])
|
||||
}
|
||||
}
|
||||
if len(matched) != 1 {
|
||||
t.Fatalf("expected exactly 1 incident for group_key %q after %d concurrent deliveries, got %d: %v",
|
||||
groupKey, callers, len(matched), matched)
|
||||
}
|
||||
|
||||
var alerts []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||
seen := map[string]bool{}
|
||||
for _, a := range alerts {
|
||||
if fp, ok := a["fingerprint"].(string); ok {
|
||||
seen[fp] = true
|
||||
}
|
||||
}
|
||||
for i := range callers {
|
||||
fp := fmt.Sprintf("fp-race-%d", i)
|
||||
if !seen[fp] {
|
||||
t.Errorf("alert %q is missing: its delivery's whole payload was silently rolled back "+
|
||||
"when it lost the race for the incident", fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPIKey_DefaultsToNeverExpiring(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "no-expiry"}), &key)
|
||||
|
||||
if key.ExpiresAt != nil {
|
||||
t.Errorf("expires_at = %v, want nil (unset expires_in_days means never expires)", *key.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysSetsExpiresAt(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "rotates", "expires_in_days": 30}), &key)
|
||||
|
||||
if key.ExpiresAt == nil {
|
||||
t.Fatal("expires_at = nil, want a timestamp roughly 30 days out")
|
||||
}
|
||||
got, err := time.Parse(time.RFC3339, *key.ExpiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at: %v", err)
|
||||
}
|
||||
want := time.Now().AddDate(0, 0, 30)
|
||||
if diff := want.Sub(got).Abs(); diff > time.Hour {
|
||||
t.Errorf("expires_at = %v, want close to %v (30 days out)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysRejectsOutOfRange(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
for _, days := range []int{-1, 3651} {
|
||||
resp := s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "bad", "expires_in_days": days})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expires_in_days=%d: status = %d, want %d", days, resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_AnExpiredKeyCannotAuthenticate(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "soon-expired", "expires_in_days": 1}), &key)
|
||||
|
||||
// A fresh key works...
|
||||
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fresh key: status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
// ...and stops working once its expiry has passed.
|
||||
s.exec(t, "UPDATE api_keys SET expires_at = $1 WHERE id = $2", time.Now().Add(-time.Hour).Unix(), key.ID)
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired key: status = %d, want %d", resp2.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListNeverReturnsTheRawKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "listed"}), new(struct {
|
||||
Key string `json:"key"`
|
||||
}))
|
||||
|
||||
var keys []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/users/1/api-keys", nil), &keys)
|
||||
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k.Name == "listed" {
|
||||
found = true
|
||||
}
|
||||
if k.Key != "" {
|
||||
t.Errorf("key %d (%s): raw key present in listing", k.ID, k.Name)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the key just created does not appear in the listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListIsSelfOrAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "apikeys-a")
|
||||
|
||||
resp := a.call(http.MethodGet, "/api/users/1/api-keys", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ const (
|
||||
// expiryGrace absorbs clock skew and notification latency before an alert
|
||||
// whose ends_at watermark has passed is treated as stale.
|
||||
expiryGrace = 5 * time.Minute
|
||||
|
||||
// archiverLockKey is the Postgres advisory lock the sweeper takes for the
|
||||
// duration of each pass, so that running more than one replica does not run
|
||||
// the sweep concurrently on all of them. Its value has no meaning beyond
|
||||
// being distinct from notifierLockKey.
|
||||
archiverLockKey int64 = 7265_0001
|
||||
)
|
||||
|
||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||
@@ -23,15 +29,25 @@ const (
|
||||
// the fallback, not the setting: each pass reads the current value from the
|
||||
// settings table, so an administrator's change takes effect on the next tick
|
||||
// instead of at the next restart.
|
||||
//
|
||||
// Each pass runs under archiverLockKey (see withAdvisoryLock), so that on more
|
||||
// than one replica only whichever instance's tick takes the lock first actually
|
||||
// sweeps; the rest skip that tick rather than racing the same pass.
|
||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
sweep := func() {
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "sweeper", func() {
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
})
|
||||
}
|
||||
|
||||
sweep()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -60,6 +76,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati
|
||||
archiveResolvedIncidents(ctx, db, archiveAfter)
|
||||
purgeAckTokens(ctx, db)
|
||||
purgeSessions(ctx, db)
|
||||
purgeRateLimits(ctx, db)
|
||||
}
|
||||
|
||||
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
|
||||
@@ -105,6 +122,10 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
for i, id := range ids {
|
||||
idList[i] = id
|
||||
}
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE alerts
|
||||
SET status = 'resolved',
|
||||
@@ -126,7 +147,7 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
continue
|
||||
}
|
||||
alertID := id
|
||||
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, nil, &alertID, nil); err != nil {
|
||||
log.Printf("sweeper: log expiry event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+83
-42
@@ -45,57 +45,85 @@ var dummyHash = sync.OnceValue(func() []byte {
|
||||
return h
|
||||
})
|
||||
|
||||
// loginLimiter counts failed logins in a fixed window, per username and per
|
||||
// client address. The username limit is what stops guessing one account; the
|
||||
// address limit is looser because every user behind the same gateway or NAT
|
||||
// shares it.
|
||||
// loginLimiter counts failed logins (and other unauthenticated attempts:
|
||||
// sign-up, OIDC/device start) in a fixed window, per key — a username, a
|
||||
// client address, or both, depending on the caller.
|
||||
//
|
||||
// Backed by Postgres rather than an in-memory map: this server runs more
|
||||
// than one replica in production (v0.37.0), and a counter that only ever
|
||||
// sees its own pod's traffic would quietly let every limit through
|
||||
// multiplied by the replica count — two loginLimiter values pointed at the
|
||||
// same db, standing in for two replicas, now share exactly one count per
|
||||
// key instead of each keeping their own.
|
||||
//
|
||||
// The window resets rather than slides, the same behavior the in-memory
|
||||
// version it replaces had: once a key's window is older than loginWindow,
|
||||
// the next fail() starts a fresh one instead of extending the stale one.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
failures map[string]*loginWindowCount
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type loginWindowCount struct {
|
||||
start time.Time
|
||||
n int
|
||||
func newLoginLimiter(db *sql.DB) *loginLimiter {
|
||||
return &loginLimiter{db: db}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{failures: map[string]*loginWindowCount{}}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
c, ok := l.failures[key]
|
||||
if !ok || time.Since(c.start) > loginWindow {
|
||||
func (l *loginLimiter) blocked(ctx context.Context, key string, max int) bool {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
var count int
|
||||
err := l.db.QueryRowContext(ctx, `
|
||||
SELECT count FROM rate_limit_counters
|
||||
WHERE key = $1 AND window_start > $2`,
|
||||
key, cutoff,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
// No row (never failed, or its window already expired): not blocked.
|
||||
// A real query error fails the same way — a rate limiter that locks
|
||||
// everyone out during a brief database hiccup is worse than one that
|
||||
// is briefly too generous.
|
||||
return false
|
||||
}
|
||||
return c.n >= max
|
||||
return count >= max
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, c := range l.failures {
|
||||
if now.Sub(c.start) > loginWindow {
|
||||
delete(l.failures, k)
|
||||
}
|
||||
}
|
||||
func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
|
||||
now := time.Now().Unix()
|
||||
windowSecs := int64(loginWindow.Seconds())
|
||||
for _, key := range keys {
|
||||
c, ok := l.failures[key]
|
||||
if !ok {
|
||||
c = &loginWindowCount{start: now}
|
||||
l.failures[key] = c
|
||||
if _, err := l.db.ExecContext(ctx, `
|
||||
INSERT INTO rate_limit_counters (key, window_start, count)
|
||||
VALUES ($1, $2, 1)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
window_start = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN $2 ELSE rate_limit_counters.window_start END,
|
||||
count = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN 1 ELSE rate_limit_counters.count + 1 END`,
|
||||
key, now, windowSecs,
|
||||
); err != nil {
|
||||
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
|
||||
}
|
||||
c.n++
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) clear(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.failures, key)
|
||||
func (l *loginLimiter) clear(ctx context.Context, key string) {
|
||||
if _, err := l.db.ExecContext(ctx, "DELETE FROM rate_limit_counters WHERE key = $1", key); err != nil {
|
||||
log.Printf("rate limiter: clear %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// purgeRateLimits deletes rate-limit windows that have expired, from the
|
||||
// sweeper — otherwise every distinct username and address this server has
|
||||
// ever seen a failed attempt from would stay a row forever.
|
||||
func purgeRateLimits(ctx context.Context, db *sql.DB) {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
res, err := db.ExecContext(ctx,
|
||||
"DELETE FROM rate_limit_counters WHERE window_start <= $1", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("sweeper: purge rate limit counters: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("sweeper: purged %d expired rate limit counter(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddr is the address a login is counted against. Behind the gateway
|
||||
@@ -171,6 +199,9 @@ func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, user
|
||||
return err
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what trips
|
||||
// this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: raw,
|
||||
@@ -198,7 +229,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
userKey := "user:" + strings.ToLower(username)
|
||||
addrKey := "addr:" + clientAddr(r)
|
||||
|
||||
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), userKey, loginMaxPerUser) || limiter.blocked(r.Context(), addrKey, loginMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
|
||||
return
|
||||
@@ -220,11 +251,11 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
}
|
||||
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
|
||||
if !match || !hash.Valid {
|
||||
limiter.fail(userKey, addrKey)
|
||||
limiter.fail(r.Context(), userKey, addrKey)
|
||||
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
|
||||
return
|
||||
}
|
||||
limiter.clear(userKey)
|
||||
limiter.clear(r.Context(), userKey)
|
||||
|
||||
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -252,6 +283,9 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
|
||||
}
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
@@ -291,9 +325,16 @@ func handleMe(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var hash sql.NullString
|
||||
var dismissed *int64
|
||||
db.QueryRowContext(r.Context(),
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||
caller.ID).Scan(&hash, &dismissed)
|
||||
caller.ID).Scan(&hash, &dismissed); err != nil {
|
||||
// fetchUser above already found this row, so an error here is a
|
||||
// transient database problem, not a missing user — worth a 500
|
||||
// rather than silently answering "no password, not dismissed",
|
||||
// which a client would otherwise take at face value.
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, meResponse{
|
||||
User: user,
|
||||
HasPassword: hash.Valid,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file is the regression test for the pattern documented throughout
|
||||
// middleware.go: every team-scoped handler calls requireTeamMember or
|
||||
// requireTeamOwner before touching data, every self-or-admin handler calls
|
||||
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
|
||||
// pattern is enforced by convention, not by the type system — a new handler
|
||||
// that forgets the call would compile and pass review on a quick read just
|
||||
// as easily as one that remembers it. These tests exercise every route that
|
||||
// carries one of those guards as a caller who should be refused, so a future
|
||||
// handler missing its guard fails CI instead of becoming a silent IDOR.
|
||||
|
||||
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
|
||||
// every team-scoped route, calls it as team A's owner against team B's
|
||||
// resources. requireTeamMember and requireTeamOwner both answer a non-member
|
||||
// with 404 (team.go's own reasoning: whether a team exists is itself
|
||||
// something only its members should learn), so every one of these must come
|
||||
// back 404 regardless of which of the two guards its handler uses.
|
||||
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-a")
|
||||
b := newTeam(t, s, "authz-b")
|
||||
|
||||
// An incident in B, to cover the ID-based routes under /api/incidents —
|
||||
// scoped by the incident's own team_id rather than a {teamID} path
|
||||
// segment, but through the same single chokepoint (incidentIDParam).
|
||||
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
|
||||
var incidents []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
|
||||
if len(incidents) == 0 {
|
||||
t.Fatal("setup: no incident in team B to test against")
|
||||
}
|
||||
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
|
||||
|
||||
bPath := "/api/teams/" + id64(b.id)
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
// Team membership/ownership itself.
|
||||
{http.MethodPut, bPath},
|
||||
{http.MethodDelete, bPath},
|
||||
{http.MethodGet, bPath + "/members"},
|
||||
{http.MethodPost, bPath + "/members"},
|
||||
{http.MethodDelete, bPath + "/members/1"},
|
||||
|
||||
// OIDC group binding.
|
||||
{http.MethodGet, bPath + "/oidc-groups"},
|
||||
{http.MethodPut, bPath + "/oidc-groups"},
|
||||
|
||||
// Invites.
|
||||
{http.MethodGet, bPath + "/invites"},
|
||||
{http.MethodPost, bPath + "/invites"},
|
||||
{http.MethodDelete, bPath + "/invites/1"},
|
||||
|
||||
// Escalation.
|
||||
{http.MethodGet, bPath + "/escalation"},
|
||||
{http.MethodPut, bPath + "/escalation"},
|
||||
|
||||
// Dead man's switches.
|
||||
{http.MethodGet, bPath + "/deadman/switches"},
|
||||
{http.MethodPost, bPath + "/deadman/switches"},
|
||||
{http.MethodPut, bPath + "/deadman/switches/1"},
|
||||
{http.MethodDelete, bPath + "/deadman/switches/1"},
|
||||
|
||||
// Integrations.
|
||||
{http.MethodGet, bPath + "/integrations"},
|
||||
{http.MethodPost, bPath + "/integrations"},
|
||||
{http.MethodPatch, bPath + "/integrations/1"},
|
||||
{http.MethodDelete, bPath + "/integrations/1"},
|
||||
|
||||
// Schedule.
|
||||
{http.MethodGet, bPath + "/schedule"},
|
||||
{http.MethodPost, bPath + "/schedule"},
|
||||
{http.MethodDelete, bPath + "/schedule/1"},
|
||||
|
||||
// Incidents, scoped by the incident's own team rather than a
|
||||
// {teamID} segment.
|
||||
{http.MethodGet, incidentPath},
|
||||
{http.MethodGet, incidentPath + "/alerts"},
|
||||
{http.MethodGet, incidentPath + "/timeline"},
|
||||
{http.MethodGet, incidentPath + "/similar"},
|
||||
{http.MethodPost, incidentPath + "/acknowledge"},
|
||||
{http.MethodDelete, incidentPath + "/acknowledge"},
|
||||
{http.MethodPost, incidentPath + "/resolve"},
|
||||
{http.MethodPost, incidentPath + "/assign"},
|
||||
{http.MethodPost, incidentPath + "/snooze"},
|
||||
{http.MethodDelete, incidentPath + "/snooze"},
|
||||
{http.MethodPost, incidentPath + "/archive"},
|
||||
{http.MethodDelete, incidentPath + "/archive"},
|
||||
{http.MethodPost, incidentPath + "/notes"},
|
||||
{http.MethodDelete, incidentPath + "/notes/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
|
||||
// in router.go directly: a signed-in, non-admin caller gets 403 from every
|
||||
// route in it, before any handler body runs.
|
||||
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-admin")
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodPost, "/api/users"},
|
||||
{http.MethodDelete, "/api/users/1"},
|
||||
{http.MethodPut, "/api/users/1/admin"},
|
||||
{http.MethodPut, "/api/users/1/disabled"},
|
||||
{http.MethodGet, "/api/admin/teams"},
|
||||
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
|
||||
{http.MethodGet, "/api/admin/settings"},
|
||||
{http.MethodPut, "/api/admin/settings"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
|
||||
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
|
||||
// user's account must be refused, the same as AdminOnly's routes, even
|
||||
// though these sit in the general authenticated group rather than behind
|
||||
// AdminOnly itself.
|
||||
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-self-a")
|
||||
b := newTeam(t, s, "authz-self-b")
|
||||
|
||||
var members []struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
|
||||
if len(members) == 0 {
|
||||
t.Fatal("setup: team B has no members")
|
||||
}
|
||||
bUserID := id64(members[0].UserID)
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/password"},
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-5
@@ -99,13 +99,24 @@ func (c Caller) ServiceAccountID() (int64, bool) {
|
||||
return c.sa.id, true
|
||||
}
|
||||
|
||||
// ServiceAccountName reports this caller's own service-account name, for a
|
||||
// handler's synchronous response — the same credential it authenticated
|
||||
// with, already resolved onto the Caller by serveAsServiceAccount, so no
|
||||
// extra query is needed.
|
||||
func (c Caller) ServiceAccountName() (string, bool) {
|
||||
if c.sa == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.sa.name, true
|
||||
}
|
||||
|
||||
// Identity is a stable, log/audit-facing string distinguishing a human
|
||||
// caller from a service account — "user:42" or "service-account:7". Not
|
||||
// wired into any database column today (incidents.go's acknowledged_by/
|
||||
// assigned_to/user_id are explicitly out of scope for this change — that
|
||||
// needs its own schema migration, tracked separately), but this is the one
|
||||
// place in the request path that already knows which kind of caller this
|
||||
// is, and that follow-up will want exactly this accessor.
|
||||
// wired into any database column — incidents.go's acknowledged_by/
|
||||
// incident_events.user_id use AsHuman()/ServiceAccountID() directly against
|
||||
// the parallel *_service_account_id columns (migration 015) instead, since a
|
||||
// column needs the id, not this rendered string. assigned_to stays
|
||||
// human-only and out of scope (terdut-server#25's follow-up).
|
||||
func (c Caller) Identity() string {
|
||||
switch {
|
||||
case c.user != nil:
|
||||
|
||||
@@ -283,6 +283,10 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet
|
||||
nameList[i] = n
|
||||
}
|
||||
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||
FROM alerts
|
||||
@@ -373,7 +377,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadma
|
||||
|
||||
alertID := hb.id
|
||||
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
|
||||
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
|
||||
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, nil, &alertID, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -415,7 +419,7 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
|
||||
time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
// The all-clear goes to whoever was paged, which enqueueResolved works out
|
||||
|
||||
@@ -72,12 +72,12 @@ func normalizeUserCode(s string) string {
|
||||
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "device:" + clientAddr(r)
|
||||
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), addrKey, deviceStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(addrKey)
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
deviceCode, deviceHash, err := randomToken()
|
||||
if err != nil {
|
||||
|
||||
@@ -229,7 +229,7 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
|
||||
// nobody. That is a policy that looks configured and is not.
|
||||
detail += ": nobody reachable"
|
||||
}
|
||||
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil {
|
||||
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -256,7 +256,7 @@ func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPoli
|
||||
incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail)
|
||||
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail)
|
||||
}
|
||||
|
||||
// pageLevel notifies every target of one level and reports who was woken.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -69,11 +72,64 @@ func respond(w http.ResponseWriter, status int, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
|
||||
// endpoint below needs — it exists so an unauthenticated caller (signup,
|
||||
// login, bootstrap) can't make the server buffer an arbitrarily large body
|
||||
// before the request is even validated.
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return decodeJSONLimit(r, v, maxBodyBytes)
|
||||
}
|
||||
|
||||
// decodeJSONLimit is decodeJSON with an explicit cap, for the one endpoint
|
||||
// (the Alertmanager webhook, see maxWebhookBodyBytes) whose real payloads can
|
||||
// legitimately be larger than maxBodyBytes.
|
||||
func decodeJSONLimit(r *http.Request, v any, limit int64) error {
|
||||
defer r.Body.Close()
|
||||
// w is nil: there is no ResponseWriter here to disable keep-alive with,
|
||||
// which net/http documents as fine — the limit is still enforced, the
|
||||
// connection just isn't closed early on a request that blows past it.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, limit)
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
// withAdvisoryLock runs fn only if it can take the named Postgres advisory lock on a
|
||||
// dedicated connection, and skips fn otherwise. This is what keeps the archiver and
|
||||
// notifier safe to run on more than one replica: whichever instance's tick gets there
|
||||
// first does the work; the rest see the lock held and simply wait for their next tick
|
||||
// instead of running the same pass concurrently.
|
||||
//
|
||||
// pg_try_advisory_lock is session-scoped, so taking and releasing it must happen on the
|
||||
// same connection, reserved via db.Conn rather than borrowed from the pool's shared
|
||||
// connections fn itself may use — and released (unlocked, then closed) before returning,
|
||||
// since a session lock otherwise outlives this call and leaks onto whatever reuses the
|
||||
// pooled connection next.
|
||||
func withAdvisoryLock(ctx context.Context, db *sql.DB, key int64, name string, fn func()) {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
log.Printf("%s: advisory lock: acquire connection: %v", name, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var locked bool
|
||||
if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&locked); err != nil {
|
||||
log.Printf("%s: advisory lock: %v", name, err)
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
return // another replica is already running this pass
|
||||
}
|
||||
defer func() {
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", key); err != nil {
|
||||
log.Printf("%s: advisory unlock: %v", name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ const (
|
||||
evAcknowledged = "acknowledged"
|
||||
evUnacknowledged = "unacknowledged"
|
||||
evAssigned = "assigned"
|
||||
evArchived = "archived"
|
||||
evUnarchived = "unarchived"
|
||||
evSnoozed = "snoozed"
|
||||
evUnsnoozed = "unsnoozed"
|
||||
evResolved = "resolved"
|
||||
@@ -65,11 +67,13 @@ const incidentSelectFrom = `
|
||||
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
|
||||
i.triggered_at,
|
||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||
i.acknowledged_by_service_account_id, acksa.name,
|
||||
i.assigned_to, asg.username, i.snoozed_until,
|
||||
i.resolved_at, i.resolution_source, i.archived_at
|
||||
FROM incidents i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
||||
LEFT JOIN service_accounts acksa ON acksa.id = i.acknowledged_by_service_account_id
|
||||
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
||||
|
||||
func scanIncident(s scanner) (models.Incident, error) {
|
||||
@@ -83,6 +87,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
||||
&i.EscalationLevel, &escalationDue,
|
||||
&triggeredAt,
|
||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||
&i.AcknowledgedByServiceAccountID, &i.AcknowledgedByServiceAccountName,
|
||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||
&resolvedAt, &i.ResolutionSource, &archivedAt,
|
||||
); err != nil {
|
||||
@@ -112,13 +117,42 @@ func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, e
|
||||
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id))
|
||||
}
|
||||
|
||||
// logEvent appends one entry to an incident's timeline. A nil userID means the
|
||||
// server acted rather than a person.
|
||||
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
|
||||
// callerActorIDs resolves the current request's caller into the pair of
|
||||
// nilable ids logEvent/acknowledgeIncidentAs expect: exactly one of userID/
|
||||
// serviceAccountID is set (never both), replacing the unchecked
|
||||
// userFromContext(ctx) zero-value reads that used to write a human-only id
|
||||
// of 0 for a service-account caller (terdut-server#25).
|
||||
func callerActorIDs(ctx context.Context) (userID, serviceAccountID *int64) {
|
||||
caller, _ := callerFromContext(ctx)
|
||||
if u, ok := caller.AsHuman(); ok {
|
||||
return &u.ID, nil
|
||||
}
|
||||
if id, ok := caller.ServiceAccountID(); ok {
|
||||
return nil, &id
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// logEvent appends one entry to an incident's timeline. userID and
|
||||
// serviceAccountID are mutually exclusive and both nilable; both nil means
|
||||
// the server acted rather than any caller (see incident_events_actor_xor_chk,
|
||||
// migration 015).
|
||||
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, serviceAccountID, alertID *int64, detail *string) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
|
||||
INSERT INTO incident_events (incident_id, type, user_id, service_account_id, alert_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
incidentID, evType, userID, serviceAccountID, alertID, detail, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// logAssignedEvent records an assignment: user_id is the assignee, and the
|
||||
// caller who performed it goes in the actor_* columns (migration 018), since
|
||||
// user_id cannot hold both.
|
||||
func logAssignedEvent(ctx context.Context, q querier, incidentID, assigneeID int64, actorUserID, actorServiceAccountID *int64) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, actor_user_id, actor_service_account_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
incidentID, evType, userID, alertID, detail, time.Now().Unix())
|
||||
incidentID, evAssigned, assigneeID, actorUserID, actorServiceAccountID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -251,7 +285,7 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// The all-clear goes only to whoever was paged in the first place, which
|
||||
@@ -260,19 +294,30 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
return true, enqueueResolved(ctx, q, incidentID)
|
||||
}
|
||||
|
||||
// acknowledgeIncident records that userID has picked an incident up, and reports
|
||||
// whether it changed anything — an already-resolved or already-acknowledged
|
||||
// incident is left alone, so a second acknowledge (a retried request, or a
|
||||
// stale push notification tapped after the web UI already acked it) is a
|
||||
// no-op rather than a second "acknowledged" timeline entry. Shared by the
|
||||
// authenticated handler and the Acknowledge button in a push notification,
|
||||
// so both write the same state and the same timeline entry.
|
||||
// acknowledgeIncident records that userID — a human — has picked an incident
|
||||
// up, and reports whether it changed anything — an already-resolved or
|
||||
// already-acknowledged incident is left alone, so a second acknowledge (a
|
||||
// retried request, or a stale push notification tapped after the web UI
|
||||
// already acked it) is a no-op rather than a second "acknowledged" timeline
|
||||
// entry. Used only by the Acknowledge button in a push notification
|
||||
// (notify_ack.go), which always resolves a human from
|
||||
// incident_ack_tokens.user_id — there is no service-account equivalent of
|
||||
// that flow, so this keeps its human-only signature; the authenticated
|
||||
// handler goes through acknowledgeIncidentAs below instead.
|
||||
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
||||
return acknowledgeIncidentAs(ctx, q, incidentID, &userID, nil)
|
||||
}
|
||||
|
||||
// acknowledgeIncidentAs is acknowledgeIncident generalized to either actor
|
||||
// kind. userID and serviceAccountID are mutually exclusive and nilable the
|
||||
// same way logEvent's are (see incidents_ack_actor_xor_chk, migration 015).
|
||||
func acknowledgeIncidentAs(ctx context.Context, q querier, incidentID int64, userID, serviceAccountID *int64) (bool, error) {
|
||||
res, err := q.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
||||
WHERE id = $3 AND status = 'triggered'`,
|
||||
userID, time.Now().Unix(), incidentID)
|
||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_by_service_account_id = $2,
|
||||
acknowledged_at = $3
|
||||
WHERE id = $4 AND status = 'triggered'`,
|
||||
userID, serviceAccountID, time.Now().Unix(), incidentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -283,7 +328,7 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, userID, serviceAccountID, nil, nil)
|
||||
}
|
||||
|
||||
// openIncidentForAlert returns the open incident an alert currently belongs to,
|
||||
|
||||
+61
-25
@@ -100,6 +100,10 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
incidents = append(incidents, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, incidents)
|
||||
}
|
||||
}
|
||||
@@ -157,9 +161,15 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
|
||||
e.service_account_id, sa.name,
|
||||
e.actor_user_id, au.username,
|
||||
e.actor_service_account_id, asa.name,
|
||||
e.alert_id, e.detail, e.created_at
|
||||
FROM incident_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
|
||||
LEFT JOIN users au ON au.id = e.actor_user_id
|
||||
LEFT JOIN service_accounts asa ON asa.id = e.actor_service_account_id
|
||||
WHERE e.incident_id = $1
|
||||
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||
if err != nil {
|
||||
@@ -173,6 +183,9 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
var e models.IncidentEvent
|
||||
var ts int64
|
||||
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
|
||||
&e.ServiceAccountID, &e.ServiceAccountName,
|
||||
&e.ActorUserID, &e.ActorUsername,
|
||||
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
|
||||
&e.AlertID, &e.Detail, &ts); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -180,6 +193,10 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
events = append(events, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, events)
|
||||
}
|
||||
}
|
||||
@@ -190,8 +207,8 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
acked, err := acknowledgeIncident(r.Context(), db, id, user.ID)
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
acked, err := acknowledgeIncidentAs(r.Context(), db, id, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -223,13 +240,14 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
|
||||
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL,
|
||||
acknowledged_by_service_account_id = NULL, acknowledged_at = NULL
|
||||
WHERE id = $1 AND resolved_at IS NULL`, id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -247,7 +265,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
// The body is optional: clients that predate resolution notes send none.
|
||||
var req struct {
|
||||
Resolution string `json:"resolution"`
|
||||
@@ -268,12 +286,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evResolved, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if req.Resolution != "" {
|
||||
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evResolutionNote, userID, saID, nil, &req.Resolution); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -311,8 +329,10 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
req.UserID, id) {
|
||||
return
|
||||
}
|
||||
// On an "assigned" event user_id is the assignee, not the actor.
|
||||
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil); err != nil {
|
||||
// On an "assigned" event user_id is the assignee; the actor goes in
|
||||
// the actor_* columns.
|
||||
actorUserID, actorSAID := callerActorIDs(r.Context())
|
||||
if err := logAssignedEvent(r.Context(), db, id, req.UserID, actorUserID, actorSAID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -363,14 +383,14 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||
until.Unix(), id) {
|
||||
return
|
||||
}
|
||||
detail := until.UTC().Format(time.RFC3339)
|
||||
if err := logEvent(r.Context(), db, id, evSnoozed, &user.ID, nil, &detail); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evSnoozed, userID, saID, nil, &detail); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -384,12 +404,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -413,6 +433,11 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||
return
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evArchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
}
|
||||
}
|
||||
@@ -433,6 +458,11 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||
return
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evUnarchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -466,27 +496,32 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
caller, _ := callerFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
now := time.Now()
|
||||
var eventID int64
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||
INSERT INTO incident_events (incident_id, type, user_id, service_account_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`, id, noteType, userID, saID, req.Content, now.Unix()).Scan(&eventID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusCreated, models.IncidentEvent{
|
||||
resp := models.IncidentEvent{
|
||||
ID: eventID,
|
||||
IncidentID: id,
|
||||
Type: noteType,
|
||||
UserID: &user.ID,
|
||||
Username: &user.Username,
|
||||
Detail: &req.Content,
|
||||
CreatedAt: now.UTC().Truncate(time.Second),
|
||||
})
|
||||
}
|
||||
if u, ok := caller.AsHuman(); ok {
|
||||
resp.UserID, resp.Username = &u.ID, &u.Username
|
||||
} else if saName, ok := caller.ServiceAccountName(); ok {
|
||||
resp.ServiceAccountID, resp.ServiceAccountName = saID, &saName
|
||||
}
|
||||
respond(w, http.StatusCreated, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,11 +539,12 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
res, err := db.ExecContext(r.Context(), `
|
||||
DELETE FROM incident_events
|
||||
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
|
||||
eventID, id, evNote, evResolutionNote, user.ID)
|
||||
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4)
|
||||
AND (user_id = $5 OR service_account_id = $6)`,
|
||||
eventID, id, evNote, evResolutionNote, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// amAlert builds one alert of a webhook payload.
|
||||
@@ -642,6 +643,106 @@ func TestIncident_ArchiveRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service accounts (terdut-server#25)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestServiceAccount_CanActOnItsTeamsIncidents is #25's regression test.
|
||||
// Before the fix: acknowledge/resolve/snooze/create-note each 500'd (writing
|
||||
// acknowledged_by/user_id = 0, violating the users(id) FK for a service
|
||||
// account), and delete-note silently matched zero rows (WHERE user_id = 0)
|
||||
// instead of deleting.
|
||||
func TestServiceAccount_CanActOnItsTeamsIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-sa", "SAIncident") // incident 1
|
||||
|
||||
// Acknowledge.
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account acknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
var inc map[string]any
|
||||
decode(t, resp, &inc)
|
||||
if inc["acknowledged_by_service_account_id"] == nil {
|
||||
t.Error("expected acknowledged_by_service_account_id to be set")
|
||||
}
|
||||
if inc["acknowledged_by_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_id to stay nil for a service-account actor, got %v", inc["acknowledged_by_id"])
|
||||
}
|
||||
|
||||
// Unacknowledge.
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/acknowledge", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unacknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Snooze, then unsnooze.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/snooze",
|
||||
map[string]string{"duration": "1h"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account snooze: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/snooze", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unsnooze: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create, then delete, a note.
|
||||
var note map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/notes",
|
||||
map[string]string{"content": "looking into it"}), ¬e)
|
||||
if note["service_account_id"] == nil {
|
||||
t.Error("expected service_account_id on the note event")
|
||||
}
|
||||
if note["user_id"] != nil {
|
||||
t.Errorf("expected no user_id on a service-account note, got %v", note["user_id"])
|
||||
}
|
||||
noteID := int(note["id"].(float64))
|
||||
delResp := s.reqAs(t, keyA, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", noteID), nil)
|
||||
delResp.Body.Close()
|
||||
if delResp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account deleting its own note: %d", delResp.StatusCode)
|
||||
}
|
||||
|
||||
// Resolve.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account resolve: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard: a human actor must still write only the human columns,
|
||||
// unaffected by the service-account branch added above.
|
||||
func TestIncident_AcknowledgeStillWritesOnlyHumanColumn(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-human-ack", "Z", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
|
||||
var inc map[string]any
|
||||
decode(t, s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil), &inc)
|
||||
if inc["acknowledged_by_id"] == nil {
|
||||
t.Error("expected acknowledged_by_id to be set for a human actor")
|
||||
}
|
||||
if inc["acknowledged_by_service_account_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_service_account_id to stay nil for a human actor, got %v",
|
||||
inc["acknowledged_by_service_account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
@@ -769,3 +870,105 @@ func contains(haystack []string, needle string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor on assign / archive / unarchive (terdut-server#35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// lastEvent returns the newest timeline event of the given type.
|
||||
func lastEvent(t *testing.T, events []map[string]any, typ string) map[string]any {
|
||||
t.Helper()
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
if events[i]["type"] == typ {
|
||||
return events[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %q event in %v", typ, eventTypes(events))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestIncident_AssignRecordsHumanActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-asg-actor", "Assignable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 2}).Body.Close()
|
||||
|
||||
ev := lastEvent(t, timeline(t, s, 1), "assigned")
|
||||
if ev["username"] != "alice" {
|
||||
t.Errorf("expected the assignee alice in username, got %v", ev["username"])
|
||||
}
|
||||
if ev["actor_user_id"] == nil || ev["actor_username"] == nil {
|
||||
t.Errorf("expected the assigning human in actor_*, got %v", ev)
|
||||
}
|
||||
if ev["actor_service_account_id"] != nil {
|
||||
t.Errorf("expected no service-account actor, got %v", ev["actor_service_account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncident_ArchiveUnarchiveRecordHumanActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-arc-actor", "Archivable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
|
||||
s.req(t, http.MethodDelete, "/api/incidents/1/archive", nil).Body.Close()
|
||||
|
||||
events := timeline(t, s, 1)
|
||||
for _, typ := range []string{"archived", "unarchived"} {
|
||||
ev := lastEvent(t, events, typ)
|
||||
if ev["user_id"] == nil || ev["service_account_id"] != nil {
|
||||
t.Errorf("%s: expected only the human actor, got %v", typ, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-sa-35", "SA35") // incident 1
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 1})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account assign: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||
resp.Body.Close()
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/archive", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account archive: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/archive", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("service account unarchive: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var events []map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/incidents/1/timeline", nil), &events)
|
||||
asg := lastEvent(t, events, "assigned")
|
||||
if asg["actor_service_account_id"] == nil || asg["actor_user_id"] != nil {
|
||||
t.Errorf("assigned: expected only the service-account actor, got %v", asg)
|
||||
}
|
||||
if asg["user_id"] == nil {
|
||||
t.Errorf("assigned: user_id must stay the assignee, got %v", asg)
|
||||
}
|
||||
for _, typ := range []string{"archived", "unarchived"} {
|
||||
ev := lastEvent(t, events, typ)
|
||||
if ev["service_account_id"] == nil || ev["user_id"] != nil {
|
||||
t.Errorf("%s: expected only the service-account actor, got %v", typ, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,28 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// securityHeaders sets headers that cost nothing to send on every response,
|
||||
// API or static site alike. nosniff is unconditional; HSTS only fires once
|
||||
// cookieSecure's signal says the browser is actually looking at this server
|
||||
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
|
||||
// neither header itself.
|
||||
//
|
||||
// max-age is 180 days rather than the usual year-plus: short enough that if
|
||||
// HTTPS here ever broke for real, the header would age out of a browser's
|
||||
// cache well within a release cycle instead of locking anyone out of a
|
||||
// working server. Raise it once this has run clean for a while.
|
||||
func securityHeaders(publicURL string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if cookieSecure(publicURL, r) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
||||
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
||||
//
|
||||
@@ -113,10 +135,16 @@ func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64)
|
||||
}
|
||||
|
||||
// apiKeyUser resolves an API key to its user and stamps its last use.
|
||||
// expires_at IS NULL OR > now is part of the lookup itself, the same way
|
||||
// serveAs's disabled_at check is: an expired key is one that cannot
|
||||
// authenticate, by construction, rather than one that happens to still
|
||||
// resolve and has to be caught afterwards.
|
||||
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||
var keyID, userID int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
||||
`SELECT id, user_id FROM api_keys
|
||||
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > $2)`,
|
||||
hashToken(token), time.Now().Unix(),
|
||||
).Scan(&keyID, &userID)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,13 @@ const (
|
||||
ackTokenTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// notifierLockKey is the Postgres advisory lock the notifier takes for the
|
||||
// duration of each pass, so that running more than one replica does not
|
||||
// deliver (or double-deliver) the same notification from more than one of
|
||||
// them at once. Its value has no meaning beyond being distinct from
|
||||
// archiverLockKey.
|
||||
const notifierLockKey int64 = 7265_0002
|
||||
|
||||
// Notification kinds, recording why a push was sent.
|
||||
const (
|
||||
notifyTriggered = "triggered"
|
||||
@@ -97,6 +105,10 @@ var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// StartNotifier delivers queued notifications until ctx is cancelled, starting
|
||||
// with an immediate pass so a restart flushes whatever the last one left behind.
|
||||
//
|
||||
// Each pass runs under notifierLockKey (see withAdvisoryLock), so that on more
|
||||
// than one replica only whichever instance's tick takes the lock first actually
|
||||
// delivers; the rest skip that tick rather than racing the same pass.
|
||||
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
if !cfg.enabled() {
|
||||
log.Print("notifier: disabled (no ntfy URL configured)")
|
||||
@@ -107,11 +119,17 @@ func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
ticker := time.NewTicker(notifyInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
NotifySweep(ctx, db, cfg)
|
||||
sweep := func() {
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "notifier", func() {
|
||||
NotifySweep(ctx, db, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
sweep()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
NotifySweep(ctx, db, cfg)
|
||||
sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -240,7 +258,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
}
|
||||
// Logged, not returned: the page has already gone out, and treating a
|
||||
// failed timeline write as a failed delivery would send it again.
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, &n.kind); err != nil {
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &n.kind); err != nil {
|
||||
log.Printf("notifier: log delivery of %d: %v", n.id, err)
|
||||
}
|
||||
sent++
|
||||
@@ -295,7 +313,7 @@ func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
|
||||
return
|
||||
}
|
||||
detail := fmt.Sprintf("%s: %s", n.kind, cause)
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, &detail); err != nil {
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, nil, &detail); err != nil {
|
||||
log.Printf("notifier: log failure of %d: %v", n.id, err)
|
||||
}
|
||||
}
|
||||
@@ -392,9 +410,11 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
|
||||
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
|
||||
}
|
||||
|
||||
title := pageTitle(inc)
|
||||
|
||||
switch n.kind {
|
||||
case notifyResolved:
|
||||
msg.Title = "Resolved: " + inc.Title
|
||||
msg.Title = "Resolved: " + title
|
||||
msg.Message = "All alerts stopped firing after " +
|
||||
humanDuration(time.Since(inc.TriggeredAt))
|
||||
msg.Priority = ntfyPriorityLow
|
||||
@@ -402,9 +422,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
|
||||
return msg
|
||||
|
||||
case notifyReminder:
|
||||
msg.Title = "Still unacknowledged: " + inc.Title
|
||||
msg.Title = "Still unacknowledged: " + title
|
||||
default:
|
||||
msg.Title = inc.Title
|
||||
msg.Title = title
|
||||
}
|
||||
|
||||
severity := derefString(inc.Severity)
|
||||
@@ -426,6 +446,48 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
|
||||
return msg
|
||||
}
|
||||
|
||||
// originLabel is the label that says where an alert came from, for a team with
|
||||
// several Kubernetes clusters behind it. It comes from Prometheus's
|
||||
// externalLabels and reaches an incident through Alertmanager's group_by; the
|
||||
// web UI reads the same label, and the README ("Several clusters, one team")
|
||||
// explains how to set it up.
|
||||
const originLabel = "cluster"
|
||||
|
||||
// pageTitle is the incident's title for a notification. A phone's lock screen
|
||||
// cuts a long title off at the end, and the incident title puts the grouping
|
||||
// labels there, so the cluster would be the first thing lost. When the incident
|
||||
// has an origin it leads instead, "[prod-eu] PodRestarting (namespace=foo)", and
|
||||
// is dropped from the parenthesis so it is not said twice. A title that is not
|
||||
// in incidentTitle's "name (k=v, k=v)" shape keeps its text and gains the prefix.
|
||||
func pageTitle(inc models.Incident) string {
|
||||
origin := inc.GroupLabels[originLabel]
|
||||
if origin == "" {
|
||||
return inc.Title
|
||||
}
|
||||
return "[" + origin + "] " + titleWithoutLabel(inc.Title, originLabel, origin)
|
||||
}
|
||||
|
||||
var titleShape = regexp.MustCompile(`(?s)^(.*?) \((.*)\)$`)
|
||||
|
||||
// titleWithoutLabel removes "key=value" from the parenthesised tail of a title
|
||||
// built by incidentTitle, and the parentheses with it if nothing else is left.
|
||||
func titleWithoutLabel(title, key, value string) string {
|
||||
m := titleShape.FindStringSubmatch(title)
|
||||
if m == nil {
|
||||
return title
|
||||
}
|
||||
var rest []string
|
||||
for _, part := range strings.Split(m[2], ", ") {
|
||||
if part != key+"="+value {
|
||||
rest = append(rest, part)
|
||||
}
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return m[1]
|
||||
}
|
||||
return m[1] + " (" + strings.Join(rest, ", ") + ")"
|
||||
}
|
||||
|
||||
// ntfy's priority scale. Max is the one that overrides the phone's quiet
|
||||
// settings, which is the whole point of paging on critical.
|
||||
const (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -164,10 +165,84 @@ func fireCritical(t *testing.T, s *ts) {
|
||||
}, "{}:{alertname=\"DiskFull\"}")
|
||||
}
|
||||
|
||||
// fireGrouped posts one critical alert whose Alertmanager group carries the
|
||||
// given labels, the way group_by puts them on the webhook.
|
||||
func fireGrouped(t *testing.T, s *ts, groupLabels map[string]string, groupKey string) {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"version": "4", "status": "firing", "groupKey": groupKey, "groupLabels": groupLabels,
|
||||
"alerts": []map[string]any{amAlert("fp-grouped", "PodRestarting", "firing",
|
||||
"2026-05-20T10:00:00Z", zeroTime, map[string]string{"severity": "critical"})},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post webhook: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("webhook returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delivery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A phone cuts a long title off at the end, and the incident title keeps the
|
||||
// grouping labels there, so the cluster leads the page instead.
|
||||
func TestNotify_ClusterLeadsTheTitle(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
|
||||
fireGrouped(t, s, map[string]string{
|
||||
"alertname": "PodRestarting", "cluster": "prod-eu", "namespace": "shop",
|
||||
}, `{}:{alertname="PodRestarting",cluster="prod-eu",namespace="shop"}`)
|
||||
s.sweepNotify(t)
|
||||
|
||||
msgs := f.messages()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 push, got %d", len(msgs))
|
||||
}
|
||||
if want := "[prod-eu] PodRestarting (namespace=shop)"; msgs[0].Title != want {
|
||||
t.Errorf("title = %q, want %q", msgs[0].Title, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the cluster is the whole grouping: no parenthesis is left behind.
|
||||
func TestNotify_ClusterAloneLeavesNoParenthesis(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{})
|
||||
|
||||
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "cluster": "prod-eu"},
|
||||
`{}:{alertname="PodRestarting",cluster="prod-eu"}`)
|
||||
s.sweepNotify(t)
|
||||
|
||||
msgs := f.messages()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 push, got %d", len(msgs))
|
||||
}
|
||||
if want := "[prod-eu] PodRestarting"; msgs[0].Title != want {
|
||||
t.Errorf("title = %q, want %q", msgs[0].Title, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a team whose alerts have no cluster label.
|
||||
func TestNotify_NoClusterKeepsTheTitle(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{})
|
||||
|
||||
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "namespace": "shop"},
|
||||
`{}:{alertname="PodRestarting",namespace="shop"}`)
|
||||
s.sweepNotify(t)
|
||||
|
||||
msgs := f.messages()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 push, got %d", len(msgs))
|
||||
}
|
||||
if want := "PodRestarting (namespace=shop)"; msgs[0].Title != want {
|
||||
t.Errorf("title = %q, want %q", msgs[0].Title, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
|
||||
|
||||
+18
-6
@@ -121,12 +121,12 @@ func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
|
||||
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "oidc:" + clientAddr(r)
|
||||
if limiter.blocked(addrKey, oidcStartMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), addrKey, oidcStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(addrKey)
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
state, stateHash, err := randomToken()
|
||||
if err != nil {
|
||||
@@ -160,6 +160,9 @@ func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, pub
|
||||
return
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie,
|
||||
Value: state,
|
||||
@@ -182,6 +185,9 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The state cookie has done its job once the callback arrives, whatever
|
||||
// the outcome.
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
|
||||
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
|
||||
@@ -189,7 +195,13 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
|
||||
// %q on both: this runs before state is checked against the
|
||||
// cookie, so error and error_description are still whatever the
|
||||
// request's query string says, not yet known to be the real
|
||||
// provider's. %q keeps a crafted value (say, one holding a
|
||||
// newline) from forging a second log line rather than just
|
||||
// being a quoted string within this one.
|
||||
log.Printf("oidc: provider returned error %q: %q", e, q.Get("error_description")) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoDenied)
|
||||
return
|
||||
}
|
||||
@@ -226,7 +238,7 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
|
||||
grants := oidc.ComputeGrants(cfg, identity.Groups)
|
||||
if !grants.Admitted {
|
||||
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject)
|
||||
log.Printf("oidc: %q (%q) is in none of the allowed groups", identity.Username, identity.Subject) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoNotAllowed)
|
||||
return
|
||||
}
|
||||
@@ -243,11 +255,11 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
if err != nil {
|
||||
var se ssoError
|
||||
if errors.As(err, &se) {
|
||||
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
|
||||
log.Printf("oidc: refused %q (%q): %v", identity.Username, identity.Subject, se) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, se)
|
||||
return
|
||||
}
|
||||
log.Printf("oidc: sign in %q: %v", identity.Username, err)
|
||||
log.Printf("oidc: sign in %q: %v", identity.Username, err) // #nosec G706 -- %q
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
// This file is internal (package api, not api_test) because loginLimiter and
|
||||
// its blocked/fail/clear methods are unexported, and TestLoginLimiter_SharedAcrossReplicas
|
||||
// specifically needs to construct two separate loginLimiter values pointed at
|
||||
// one database — standing in for two replicas — which only this package can
|
||||
// do. It duplicates testdb_test.go's newTestDB/withSearchPath rather than
|
||||
// importing them: those live in the separate api_test package, compiled from
|
||||
// this directory's external test files, and are not visible here. Same
|
||||
// reasoning as advisory_lock_test.go, which makes the same trade for the
|
||||
// same reason.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
var rateLimiterSchemaSeq int
|
||||
|
||||
// rateLimiterTestDB returns a migrated database private to this test.
|
||||
func rateLimiterTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||
"Run `make test-db` for a local one, then\n" +
|
||||
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||
}
|
||||
|
||||
rateLimiterSchemaSeq++
|
||||
schema := fmt.Sprintf("test_rl_%d_%d", os.Getpid(), rateLimiterSchemaSeq)
|
||||
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
|
||||
database, err := db.Open(rateLimiterWithSearchPath(dsn, schema))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
database.Close()
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func rateLimiterWithSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
|
||||
func TestLoginLimiter_BlocksAtMax(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
for range 3 {
|
||||
if l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("blocked before reaching max")
|
||||
}
|
||||
l.fail(ctx, "k")
|
||||
}
|
||||
if !l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("not blocked after reaching max")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_ClearResetsTheCount(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "k")
|
||||
l.fail(ctx, "k")
|
||||
l.clear(ctx, "k")
|
||||
|
||||
if l.blocked(ctx, "k", 1) {
|
||||
t.Fatal("still blocked after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_KeysAreIndependent(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "a")
|
||||
if l.blocked(ctx, "b", 1) {
|
||||
t.Fatal("failing one key blocked an unrelated one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLimiter_SharedAcrossReplicas is the regression test for the gap
|
||||
// this migration closes: an in-memory limiter would let each replica count
|
||||
// independently, so a caller hitting two different pods could rack up
|
||||
// max*replicaCount failures before either one blocked. Two loginLimiter
|
||||
// values sharing one database, standing in for two replicas behind the same
|
||||
// load balancer, must instead see one combined count.
|
||||
func TestLoginLimiter_SharedAcrossReplicas(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
replicaA := newLoginLimiter(database)
|
||||
replicaB := newLoginLimiter(database)
|
||||
|
||||
const max = 4
|
||||
// Alternate which "replica" records the failure, as a real deployment
|
||||
// would split requests across pods.
|
||||
for i := range max {
|
||||
replica := replicaA
|
||||
if i%2 == 1 {
|
||||
replica = replicaB
|
||||
}
|
||||
if replicaA.blocked(ctx, "k", max) || replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatalf("blocked after only %d of %d failures", i, max)
|
||||
}
|
||||
replica.fail(ctx, "k")
|
||||
}
|
||||
|
||||
if !replicaA.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica A does not see the combined count as blocked")
|
||||
}
|
||||
if !replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica B does not see the combined count as blocked")
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,14 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
// One limiter each, both process-wide for the life of the router: login
|
||||
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||
loginLimit := newLoginLimiter()
|
||||
signupLimiter := newLoginLimiter()
|
||||
oidcLimit := newLoginLimiter()
|
||||
loginLimit := newLoginLimiter(db)
|
||||
signupLimiter := newLoginLimiter(db)
|
||||
oidcLimit := newLoginLimiter(db)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders(notify.PublicURL))
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
@@ -111,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||
|
||||
|
||||
@@ -235,6 +235,9 @@ func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to strin
|
||||
|
||||
clause := strings.Join(where, " AND ")
|
||||
|
||||
// #nosec G202 -- clause is built from sqlArgs.add's "$N" placeholders
|
||||
// only, never a value; every value travels through args.all() as a
|
||||
// bound parameter. See the sqlArgs doc comment in helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||
FROM schedule_entries s
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSecurityHeaders_NosniffAlwaysSet(t *testing.T) {
|
||||
s := newTS(t) // no PublicURL: the HTTPS signal is off
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q, want unset without an https PublicURL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_HSTSWhenPublicURLIsHTTPS(t *testing.T) {
|
||||
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.Header.Get("Strict-Transport-Security")
|
||||
if !strings.HasPrefix(got, "max-age=") || !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("Strict-Transport-Security = %q, want a max-age with includeSubDomains", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request body size limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody confirms an
|
||||
// unauthenticated endpoint can't be made to buffer an arbitrarily large body:
|
||||
// past maxBodyBytes, decodeJSON fails exactly as it would on any other
|
||||
// malformed body, rather than the server reading the whole thing first.
|
||||
func TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB, past the 1 MiB default
|
||||
body := []byte(`{"username":"` + string(huge) + `","password":"x"}`)
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /api/login: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (oversized body treated as invalid)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault confirms the
|
||||
// Alertmanager webhook's separate, larger cap actually takes effect: a body
|
||||
// bigger than the ordinary default but within maxWebhookBodyBytes is still
|
||||
// accepted, not rejected by the smaller limit every other endpoint gets.
|
||||
func TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// Padding kept inside one alert's annotation, comfortably past the 1 MiB
|
||||
// default and still well under the webhook's 8 MiB cap.
|
||||
padding := strings.Repeat("a", 3<<20) // 3 MiB
|
||||
payload := `{"version":"4","status":"firing","groupKey":"big-group",` +
|
||||
`"groupLabels":{"alertname":"BigAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"BigAlert"},"annotations":{"note":"` + padding + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-big"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d (body under the webhook's own cap)", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookRejectsPastItsOwnCap confirms the webhook's larger
|
||||
// cap is still a cap, not an exemption from one.
|
||||
func TestBodySizeLimit_WebhookRejectsPastItsOwnCap(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := strings.Repeat("a", 9<<20) // 9 MiB, past the 8 MiB webhook cap
|
||||
payload := `{"version":"4","status":"firing","groupKey":"huge-group",` +
|
||||
`"groupLabels":{"alertname":"HugeAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"HugeAlert"},"annotations":{"note":"` + huge + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-huge"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (body past the webhook's own cap)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ var errInviteUnusable = errors.New("invite is not usable")
|
||||
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addr := clientAddr(r)
|
||||
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
||||
if limiter.blocked(r.Context(), "signup:"+addr, maxSignupsPerAddr) {
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||
return
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
var err error
|
||||
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||
if err != nil {
|
||||
limiter.fail("signup:" + addr)
|
||||
limiter.fail(r.Context(), "signup:"+addr)
|
||||
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +108,10 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
counts[hr] = cnt
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Hour int `json:"hour"`
|
||||
@@ -146,6 +154,10 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
counts[dow] = cnt
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
type entry struct {
|
||||
|
||||
+80
-3
@@ -116,6 +116,10 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
u.DisabledAt = unixPtr(disabled)
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +238,11 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
|
||||
// rotation policy, tight enough to reject a typo (a year in hours, say) that
|
||||
// would otherwise mint a key that outlives the server by decades.
|
||||
const maxAPIKeyExpiryDays = 3650 // ~10 years
|
||||
|
||||
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
@@ -247,6 +256,11 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
// ExpiresInDays is optional and, left zero, means the key never
|
||||
// expires — the only behavior any key had before this field
|
||||
// existed, so an existing integration that does not send it is
|
||||
// unaffected.
|
||||
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
@@ -256,6 +270,10 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
|
||||
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
|
||||
return
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
||||
@@ -268,18 +286,77 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
var expiresAt *int64
|
||||
var expiresAtTime *time.Time
|
||||
if req.ExpiresInDays > 0 {
|
||||
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
|
||||
u := t.Unix()
|
||||
expiresAt = &u
|
||||
expiresAtTime = &t
|
||||
}
|
||||
var keyID int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
userID, hash, req.Name).Scan(&keyID); err != nil {
|
||||
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||
key := models.APIKey{
|
||||
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
|
||||
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
|
||||
}
|
||||
respond(w, http.StatusCreated, key)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListAPIKeys lists a user's own API keys: never the raw key itself
|
||||
// (only ever returned once, at creation), just enough to tell them apart,
|
||||
// see which are stale (last_used_at) and which are about to stop working
|
||||
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
|
||||
// already produce, with no endpoint to read it back until now.
|
||||
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
`SELECT id, name, created_at, last_used_at, expires_at
|
||||
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.APIKey{}
|
||||
for rows.Next() {
|
||||
var k models.APIKey
|
||||
var created int64
|
||||
var lastUsed, expires *int64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
k.UserID = userID
|
||||
k.CreatedAt = time.Unix(created, 0).UTC()
|
||||
k.LastUsedAt = unixPtr(lastUsed)
|
||||
k.ExpiresAt = unixPtr(expires)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
@@ -62,6 +63,16 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// migrationLockKey is the Postgres advisory lock Migrate holds for its whole
|
||||
// run. Two replicas starting at once would otherwise race the check-then-apply
|
||||
// loop below against schema_migrations: the loser could crash on a
|
||||
// duplicate-key insert, or contend with the winner's uncommitted DDL. Blocking
|
||||
// (pg_advisory_lock, not pg_try_advisory_lock as the archiver and notifier
|
||||
// use): on boot there is no later tick to defer to, so the right behaviour is
|
||||
// to wait for the other replica to finish migrating, not to skip ahead and
|
||||
// start serving against an unmigrated schema.
|
||||
const migrationLockKey int64 = 7265_0003
|
||||
|
||||
// Migrate applies every embedded migration that has not been applied yet, in
|
||||
// filename order, recording each in schema_migrations.
|
||||
//
|
||||
@@ -69,6 +80,22 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
// migration that failed half way used to leave the schema in whatever state it
|
||||
// had reached. Postgres has transactional DDL, so the rollback is real.
|
||||
func Migrate(db *sql.DB) error {
|
||||
ctx := context.Background()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: acquire connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||
return fmt.Errorf("migrate: acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
||||
log.Printf("migrate: release advisory lock: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// TERDUT_TEST_DSN must point at a database the test role may create schemas
|
||||
// in; see internal/api/testdb_test.go for the fuller rationale this mirrors.
|
||||
// An unset DSN fails rather than skips, deliberately.
|
||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||
|
||||
// TestMigrate_ConcurrentCallersDoNotRace reproduces two replicas starting at
|
||||
// once against a brand-new, unmigrated schema: both call db.Migrate at the
|
||||
// same time. Before migrationLockKey, the loser could crash on a
|
||||
// duplicate-key insert into schema_migrations, or contend with the winner's
|
||||
// uncommitted DDL; with the advisory lock, one blocks until the other
|
||||
// finishes and both return cleanly.
|
||||
func TestMigrate_ConcurrentCallersDoNotRace(t *testing.T) {
|
||||
dsn := os.Getenv(testDSNEnv)
|
||||
if dsn == "" {
|
||||
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
||||
"Run `make test-db` for a local one, then\n"+
|
||||
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
||||
testDSNEnv, testDSNEnv)
|
||||
}
|
||||
|
||||
schema := fmt.Sprintf("migrate_race_%d", os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
scoped := withSearchPath(dsn, schema)
|
||||
|
||||
const callers = 2
|
||||
errs := make([]error, callers)
|
||||
var wg sync.WaitGroup
|
||||
for i := range callers {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
database, err := db.Open(scoped)
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("open: %w", err)
|
||||
return
|
||||
}
|
||||
defer database.Close()
|
||||
errs[i] = db.Migrate(database)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("Migrate #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir: %v", err)
|
||||
}
|
||||
var want int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
want++
|
||||
}
|
||||
}
|
||||
|
||||
check, err := sql.Open("pgx", scoped)
|
||||
if err != nil {
|
||||
t.Fatalf("connect for verification: %v", err)
|
||||
}
|
||||
defer check.Close()
|
||||
|
||||
var got int
|
||||
if err := check.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&got); err != nil {
|
||||
t.Fatalf("count schema_migrations: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("schema_migrations has %d row(s) after two concurrent Migrate calls, want %d (one per migration file, no duplicates)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// withSearchPath pins a DSN to one schema. Copied from
|
||||
// internal/api/testdb_test.go rather than shared: that helper lives in the
|
||||
// api_test package, a separate compiled package this one cannot import.
|
||||
func withSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Service-account actors on incident mutations (terdut-server#25). A
|
||||
-- team-scoped service account acknowledging/resolving/snoozing/noting an
|
||||
-- incident is not a users row, so it cannot be written into
|
||||
-- acknowledged_by/incident_events.user_id — doing so either violates the
|
||||
-- users(id) FK (new rows) or, for incident_events.user_id, silently matches
|
||||
-- zero rows on delete. These columns are the service-account-shaped parallel
|
||||
-- to the existing human ones: nullable, mutually exclusive with their human
|
||||
-- counterpart, ON DELETE SET NULL so a deleted service account doesn't take
|
||||
-- the incident history with it.
|
||||
ALTER TABLE incidents
|
||||
ADD COLUMN acknowledged_by_service_account_id BIGINT
|
||||
REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD COLUMN service_account_id BIGINT
|
||||
REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
-- At most one actor kind per row: both NULL ("the server acted") is valid,
|
||||
-- exactly one set is valid, both set is a bug this constraint refuses to
|
||||
-- store rather than silently accepting.
|
||||
ALTER TABLE incidents
|
||||
ADD CONSTRAINT incidents_ack_actor_xor_chk CHECK (
|
||||
acknowledged_by IS NULL OR acknowledged_by_service_account_id IS NULL
|
||||
);
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD CONSTRAINT incident_events_actor_xor_chk CHECK (
|
||||
user_id IS NULL OR service_account_id IS NULL
|
||||
);
|
||||
|
||||
CREATE INDEX incidents_acknowledged_by_service_account_id_idx
|
||||
ON incidents(acknowledged_by_service_account_id);
|
||||
CREATE INDEX incident_events_service_account_id_idx
|
||||
ON incident_events(service_account_id);
|
||||
|
||||
-- assigned_to_service_account_id is deliberately not added here: it would sit
|
||||
-- unpopulated until handleIncidentAssign itself tracks an actor, which is a
|
||||
-- separate, pre-existing gap (it records the assignee today, never the
|
||||
-- actor, for humans either) tracked in its own follow-up issue.
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Backs the rate limiters (failed logins, sign-ups, OIDC/device start) with
|
||||
-- Postgres instead of an in-memory map, now that the server runs more than
|
||||
-- one replica in production (v0.37.0): a counter that only ever sees its own
|
||||
-- pod's traffic quietly let every one of these limits through multiplied by
|
||||
-- the replica count.
|
||||
--
|
||||
-- window_start is the start of the current fixed window for key, in the same
|
||||
-- "unix seconds" shape every other timestamp in this schema uses. The window
|
||||
-- resets rather than slides, matching the in-memory limiter it replaces:
|
||||
-- once a key's window is older than the limiter's window length, the next
|
||||
-- failure starts a fresh one instead of extending the stale one.
|
||||
CREATE TABLE rate_limit_counters (
|
||||
key TEXT PRIMARY KEY,
|
||||
window_start BIGINT NOT NULL,
|
||||
count INT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Optional expiry on a user's own API keys. NULL (the existing default for
|
||||
-- every row already in this table) means "never expires" -- the same
|
||||
-- behavior these keys have always had, so no existing integration breaks.
|
||||
-- Service account keys are deliberately NOT touched: they are a different
|
||||
-- table, managed by automation, and already distinguished by their own
|
||||
-- "tdsa_" prefix.
|
||||
ALTER TABLE api_keys ADD COLUMN expires_at BIGINT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Who performed an assignment (terdut-server#35). On an 'assigned' event
|
||||
-- incident_events.user_id is the assignee, so the actor needs columns of its
|
||||
-- own. Only populated for 'assigned' events; every other event type keeps
|
||||
-- using user_id/service_account_id for the actor. Older 'assigned' rows stay
|
||||
-- NULL (the actor was never recorded). Same shape as migration 015: nullable,
|
||||
-- mutually exclusive, ON DELETE SET NULL.
|
||||
--
|
||||
-- assigned_to_service_account_id is still deliberately not added: making
|
||||
-- service accounts assignable is a separate change (request body, assignee
|
||||
-- picker, notifier, filters).
|
||||
ALTER TABLE incident_events
|
||||
ADD COLUMN actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN actor_service_account_id BIGINT REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD CONSTRAINT incident_events_assign_actor_xor_chk CHECK (
|
||||
actor_user_id IS NULL OR actor_service_account_id IS NULL
|
||||
);
|
||||
|
||||
CREATE INDEX incident_events_actor_user_id_idx ON incident_events(actor_user_id);
|
||||
CREATE INDEX incident_events_actor_service_account_id_idx ON incident_events(actor_service_account_id);
|
||||
+36
-10
@@ -43,6 +43,13 @@ type Incident struct {
|
||||
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
|
||||
// AcknowledgedByServiceAccountID/Name are the service-account-shaped
|
||||
// parallel to AcknowledgedByID/User above: mutually exclusive with it,
|
||||
// populated when a service account (not a human) acknowledged this
|
||||
// incident. See migration 015 and terdut-server#25.
|
||||
AcknowledgedByServiceAccountID *int64 `json:"acknowledged_by_service_account_id,omitempty"`
|
||||
AcknowledgedByServiceAccountName *string `json:"acknowledged_by_service_account,omitempty"`
|
||||
|
||||
AssignedToID *int64 `json:"assigned_to_id,omitempty"`
|
||||
AssignedToUser *string `json:"assigned_to,omitempty"`
|
||||
|
||||
@@ -67,17 +74,36 @@ type Incident struct {
|
||||
// and is the only history this server keeps — alert rows are mutated in place.
|
||||
//
|
||||
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
|
||||
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. A nil UserID
|
||||
// means the server acted rather than a person.
|
||||
// unacknowledged, assigned, archived, unarchived, snoozed, unsnoozed,
|
||||
// resolved, note. UserID and
|
||||
// ServiceAccountID are mutually exclusive; both nil means the server acted
|
||||
// rather than any caller.
|
||||
type IncidentEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
IncidentID int64 `json:"incident_id"`
|
||||
Type string `json:"type"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
AlertID *int64 `json:"alert_id,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
IncidentID int64 `json:"incident_id"`
|
||||
Type string `json:"type"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
|
||||
// ServiceAccountID/Name are the service-account-shaped parallel to
|
||||
// UserID/Username above: mutually exclusive with it, populated when a
|
||||
// service account (not a human, and not nil-meaning-the-server-acted)
|
||||
// performed this event. Named Name, not Username — a ServiceAccount has
|
||||
// a Name field, not a Username. See migration 015 and terdut-server#25.
|
||||
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
|
||||
ServiceAccountName *string `json:"service_account_name,omitempty"`
|
||||
|
||||
// Actor* name who performed an 'assigned' event, whose UserID is the
|
||||
// assignee. Mutually exclusive; unset on every other event type and on
|
||||
// assignments made before migration 018. See terdut-server#35.
|
||||
ActorUserID *int64 `json:"actor_user_id,omitempty"`
|
||||
ActorUsername *string `json:"actor_username,omitempty"`
|
||||
ActorServiceAccountID *int64 `json:"actor_service_account_id,omitempty"`
|
||||
ActorServiceAccountName *string `json:"actor_service_account_name,omitempty"`
|
||||
|
||||
AlertID *int64 `json:"alert_id,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SimilarIncident is an earlier, resolved incident with the same signature as
|
||||
|
||||
@@ -37,5 +37,13 @@ type APIKey struct {
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
|
||||
// ExpiresAt is nil for a key that never expires, which is every key
|
||||
// created before this field existed and still the default for a new one
|
||||
// unless its creator asks otherwise (see handleCreateAPIKey's
|
||||
// expires_in_days). AuthMiddleware stops accepting a key once this
|
||||
// passes; nothing deletes the row for it.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
}
|
||||
|
||||
+331
-94
@@ -10,33 +10,33 @@
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eff1f4;
|
||||
--surface-hover: #f7f8fa;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cfd3da;
|
||||
--border: #d9dde4;
|
||||
--border-strong: #c5cad3;
|
||||
--text: #16181d;
|
||||
--muted: #5b626e;
|
||||
--faint: #8a909b;
|
||||
--faint: #676d79;
|
||||
|
||||
--accent: #2f5bd3;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #e8eefc;
|
||||
|
||||
--crit: #d0342c;
|
||||
--crit: #c22d26;
|
||||
--crit-soft: #fdecea;
|
||||
--warn: #b86e00;
|
||||
--warn: #8f5500;
|
||||
--warn-soft: #fdf3e1;
|
||||
--info: #2f6fdf;
|
||||
--info: #245fc7;
|
||||
--info-soft: #e9f0fd;
|
||||
--ok: #1d7f4c;
|
||||
--ok: #1a7445;
|
||||
--ok-soft: #e6f5ec;
|
||||
--snooze: #6b5bd2;
|
||||
--snooze: #6050c8;
|
||||
--snooze-soft: #efedfb;
|
||||
|
||||
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||
tell six people apart and the palette above only has four that are not
|
||||
already an alarm. */
|
||||
--teal: #0f7d8c;
|
||||
--teal: #0c7180;
|
||||
--teal-soft: #e3f4f6;
|
||||
--pink: #b3427e;
|
||||
--pink: #a63b73;
|
||||
--pink-soft: #fbe8f2;
|
||||
|
||||
--radius: 10px;
|
||||
@@ -72,17 +72,21 @@
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||
a declaration list between a media query and an attribute selector. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a20;
|
||||
--surface-2: #1f232b;
|
||||
--surface-hover: #1c2027;
|
||||
--border: #2a2f38;
|
||||
--border-strong: #394050;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #737a87;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
@@ -108,6 +112,42 @@
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
--accent-soft: #1d2640;
|
||||
|
||||
--crit: #ff6b61;
|
||||
--crit-soft: #3a1c1b;
|
||||
--warn: #f0b140;
|
||||
--warn-soft: #362a14;
|
||||
--info: #74a3ff;
|
||||
--info-soft: #1a2640;
|
||||
--ok: #4cc488;
|
||||
--ok-soft: #15301f;
|
||||
--snooze: #a89bff;
|
||||
--snooze-soft: #262245;
|
||||
|
||||
--teal: #4fc2d4;
|
||||
--teal-soft: #0f2e33;
|
||||
--pink: #f07fb8;
|
||||
--pink-soft: #3a1c2d;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||
.open-pill.all-clear::before { content: none; }
|
||||
.open-pill .icon { width: 14px; height: 14px; }
|
||||
|
||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||
left sidebar from 900px, where the desktop block below redeclares display
|
||||
@@ -252,27 +296,36 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.nav-brand { display: none; }
|
||||
.nav-sep, .nav-avatar { display: none; }
|
||||
/* Hidden here (shown from 900px below): on the phone bar the team switcher
|
||||
lives in the topbar instead, as #team-selector-mobile. */
|
||||
.nav-team-selector { display: none; }
|
||||
.nav-link-secondary { display: none; }
|
||||
.nav-link {
|
||||
position: relative;
|
||||
/* flex: 1 spreads the tabs evenly across the bar's width; the desktop
|
||||
block below cancels it back to a natural-width row item. */
|
||||
flex: 1 1 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||
color: var(--muted); font-size: 12px; font-weight: 600;
|
||||
/* The More tab is a <button>; without this it keeps the browser's grey box
|
||||
and reads as a highlighted tab beside four plain links. */
|
||||
background: none; border: 0; font-family: inherit; cursor: pointer;
|
||||
/* min-width lets a column shrink below its label's natural width, which is
|
||||
what stops six tabs widening the bar past the screen. */
|
||||
min-width: 0; padding: 0 2px;
|
||||
}
|
||||
/* Must come after .nav-link above: same specificity (one class each), so
|
||||
whichever is later in the file wins for an element wearing both classes,
|
||||
and this needs to beat .nav-link's display:flex here on the phone bar. */
|
||||
.nav-link-secondary { display: none; }
|
||||
.nav-label {
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||
/* The open tab's icon is filled, so it is not told apart by colour alone. */
|
||||
.nav-link[aria-current="page"] svg { fill: currentColor; fill-opacity: 0.16; }
|
||||
.nav-badge {
|
||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||
min-width: 18px; height: 18px; padding: 0 5px;
|
||||
@@ -287,13 +340,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
|
||||
.nav-team-selector,
|
||||
.team-selector-mobile {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
background: var(--surface); color: var(--text);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
padding: 4px 12px; max-width: 100%;
|
||||
}
|
||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||
|
||||
@@ -323,6 +376,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
/* ---------- chips ---------- */
|
||||
|
||||
.chips {
|
||||
position: relative;
|
||||
display: flex; gap: 6px;
|
||||
padding: 12px 16px 8px;
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
@@ -336,15 +390,18 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--surface); color: var(--muted);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.theme-picker { padding: 0 0 8px; }
|
||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||
/* Pinned to the visible right edge of the scrolling row (sticky, not
|
||||
absolute, so it tracks the scroll position rather than the content). */
|
||||
.chips-fade {
|
||||
position: sticky; right: -1px; flex: none;
|
||||
width: 24px; margin-left: -24px;
|
||||
background: linear-gradient(to right, transparent, var(--bg));
|
||||
pointer-events: none;
|
||||
/* Nothing in it: step back so the chips that have something stand out. */
|
||||
.chip .count.zero { opacity: 0.4; }
|
||||
/* A scrolling strip fades on the right edge while there is more to scroll to
|
||||
(fadeOnOverflow in ui.js sets data-more). A mask on the strip itself, not an
|
||||
element inside it: anything inside a scroller scrolls away with the content,
|
||||
which is what the old overlay did. */
|
||||
[data-more] {
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
|
||||
/* ---------- lists ---------- */
|
||||
@@ -370,7 +427,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--sev, var(--border-strong));
|
||||
}
|
||||
.row-title {
|
||||
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
|
||||
/* Two lines, not one: titles differ at the end ("PodRestarting
|
||||
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
|
||||
}
|
||||
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
|
||||
.row-meta {
|
||||
@@ -383,7 +443,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
/* Which team's queue a row came from. Only rendered for somebody in more than
|
||||
one team, so it never repeats the same word down the whole list. */
|
||||
/* Separates the status chips from the team chips in the queue's filter row. */
|
||||
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
|
||||
|
||||
.row-team {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
@@ -429,6 +488,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.badge.plain::before { display: none; }
|
||||
.badge .badge-icon, .origin-chip .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
|
||||
/* The cluster an incident or alert came from. Same shape as a badge, coloured
|
||||
from the rcN palette (see originClass in format.js), never the severity one. */
|
||||
.origin-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
|
||||
padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||
@@ -464,10 +532,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
/* A copy of the sticky actionbar's primary button, right under the status
|
||||
it responds to — see quickActions() in incident.js. */
|
||||
.detail-quick-actions { margin-bottom: 14px; }
|
||||
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
@@ -510,11 +574,25 @@ 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; }
|
||||
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
|
||||
/* 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 {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
min-height: 44px; margin: 0 -6px; padding: 0 6px; border-radius: var(--radius-sm);
|
||||
cursor: pointer; color: var(--muted); font-size: 14px; font-weight: 600; list-style: none;
|
||||
}
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
details > summary::before { content: "▸ "; }
|
||||
details[open] > summary::before { content: "▾ "; }
|
||||
details > summary::before {
|
||||
content: ""; flex: none; width: 7px; height: 7px; margin: 0 5px 0 3px;
|
||||
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
||||
transform: rotate(-45deg); transition: transform 0.12s;
|
||||
}
|
||||
details[open] > summary::before { transform: rotate(45deg); }
|
||||
details > summary:hover { background: var(--surface-2); color: var(--text); }
|
||||
details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
details[open] > summary { margin-bottom: 8px; }
|
||||
@media (hover: hover) and (pointer: fine) { details > summary { min-height: 32px; } }
|
||||
@media (prefers-reduced-motion: reduce) { details > summary::before { transition: none; } }
|
||||
|
||||
/* One .tl-phase per status the incident has been through (see
|
||||
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
|
||||
@@ -536,18 +614,26 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.tl-item::before {
|
||||
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
|
||||
}
|
||||
.tl-item:first-child::before { top: 16px; }
|
||||
.tl-item:last-child::before { bottom: calc(100% - 16px); }
|
||||
.tl-item:first-child::before { top: 18px; }
|
||||
.tl-item:last-child::before { bottom: calc(100% - 18px); }
|
||||
.tl-dot {
|
||||
position: relative; z-index: 1;
|
||||
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
|
||||
background: var(--surface); border: 2px solid var(--faint);
|
||||
display: grid; place-items: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
|
||||
}
|
||||
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
|
||||
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
|
||||
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
|
||||
.tl-snoozed .tl-dot { border-color: var(--snooze); }
|
||||
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
|
||||
/* An event with no icon keeps the old plain dot. */
|
||||
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
|
||||
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
|
||||
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
|
||||
.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
|
||||
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
|
||||
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
|
||||
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
|
||||
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
|
||||
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
|
||||
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
|
||||
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
|
||||
.tl-body { min-width: 0; font-size: 14px; }
|
||||
.tl-text { overflow-wrap: anywhere; }
|
||||
.tl-text .who { font-weight: 650; }
|
||||
@@ -576,6 +662,10 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
}
|
||||
.actionbar .btn { min-height: 48px; }
|
||||
.actionbar .btn-primary { flex: 1; font-size: 16px; }
|
||||
/* The extra actions that only phones hide behind "More" — see actionBar() and
|
||||
extraActions() in incident.js. Hidden by default; the desktop block below
|
||||
shows them and hides the now-redundant More button instead. */
|
||||
.action-extra { display: none; }
|
||||
|
||||
.detail-placeholder {
|
||||
display: grid; place-items: center; height: 100%;
|
||||
@@ -639,7 +729,6 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
|
||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
||||
.avatar {
|
||||
flex: none; display: grid; place-items: center;
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
@@ -647,40 +736,62 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||
}
|
||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.now-name { font-size: 20px; font-weight: 750; }
|
||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||
is easier to spot there than this plain accent-coloured text. */
|
||||
.you-badge { margin-left: 6px; }
|
||||
|
||||
.week-nav { display: flex; align-items: center; gap: 4px; }
|
||||
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
|
||||
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
|
||||
ISO week number as secondary text inside it — a separate class from
|
||||
.label above (team.js's month-nav uses that one) so its <small> isn't
|
||||
caught by the unrelated .label > span styling meant for label chips. */
|
||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||
.days { list-style: none; margin: 0; padding: 0; }
|
||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
||||
.day + .day { border-top: 1px solid var(--border); }
|
||||
.day-name { font-weight: 650; }
|
||||
.day-date { color: var(--faint); font-size: 13px; }
|
||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
||||
/* A run of several days held by the same person (or left empty), replacing
|
||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
||||
.day.range { grid-template-columns: 1fr auto; }
|
||||
.day-range { font-weight: 650; }
|
||||
.day.today { background: var(--accent-soft); }
|
||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
||||
.day.past { opacity: 0.6; }
|
||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
||||
read fine layered (today's own row is almost always one of yours too). */
|
||||
.day.mine { background: var(--accent-soft); }
|
||||
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||
.oncall-grid { display: grid; gap: 0; }
|
||||
.oncall-main, .oncall-side { min-width: 0; }
|
||||
.hero-list { display: grid; gap: 12px; }
|
||||
|
||||
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||
|
||||
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||
crowding the date between them. */
|
||||
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||
.strip-day {
|
||||
position: relative; min-width: 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||
}
|
||||
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||
.strip-date { font-size: 15px; font-weight: 650; }
|
||||
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||
.strip-day.past { opacity: 0.6; }
|
||||
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||
reads as a marker and not as a pressed button. The accent stays for things
|
||||
you can act on. */
|
||||
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||
.strip-day.today::before {
|
||||
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||
border-radius: 0 0 3px 3px; background: var(--text);
|
||||
}
|
||||
.strip-day.today .strip-name { color: var(--text); }
|
||||
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||
@@ -740,6 +851,17 @@ kbd {
|
||||
.nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||
.nav-link-secondary { display: flex; }
|
||||
.nav-more-btn { display: none; }
|
||||
.nav-sep { display: block; height: 1px; margin: 8px 10px; background: var(--border); }
|
||||
/* The account link sits at the foot of the sidebar, as the signed-in person. */
|
||||
.nav-sep-account { margin-top: auto; }
|
||||
.nav-account .nav-avatar {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 28px; height: 28px; margin: -4px 0 -4px -4px; border-radius: 50%;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
font-size: 13px; font-weight: 750; text-transform: uppercase;
|
||||
}
|
||||
.nav-account .nav-avatar:not([hidden]) ~ .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-account svg:has(~ .nav-avatar:not([hidden])) { display: none; }
|
||||
.nav-link {
|
||||
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||
@@ -747,6 +869,11 @@ kbd {
|
||||
}
|
||||
.nav-link:hover { background: var(--surface-2); }
|
||||
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
||||
/* A 3px bar on the active item, as well as the tint. */
|
||||
.nav-link[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px;
|
||||
border-radius: 0 3px 3px 0; background: var(--accent);
|
||||
}
|
||||
.nav-link svg { width: 20px; height: 20px; }
|
||||
.nav-badge { position: static; margin-left: auto; }
|
||||
|
||||
@@ -764,9 +891,15 @@ kbd {
|
||||
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||
.pane-list .chip-sep { display: none; }
|
||||
.chips-fade { display: none; }
|
||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||
/* With nothing selected there is no detail to show next to, so the list
|
||||
takes the whole row instead of leaving the second column as dead space
|
||||
around the placeholder text. Selecting an incident (.has-detail) drops
|
||||
back to the base minmax(340,420) 1fr rule above. */
|
||||
.view-queue:not(.has-detail) { grid-template-columns: 1fr; }
|
||||
.view-queue:not(.has-detail) .pane-list { border-right: 0; }
|
||||
/* Full width reads better capped than edge-to-edge on a very wide monitor,
|
||||
matching .detail's own cap below. */
|
||||
.view-queue:not(.has-detail) .list { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* On desktop the list stays visible next to the detail. */
|
||||
.app.detail-open .nav { display: flex; }
|
||||
@@ -780,8 +913,12 @@ kbd {
|
||||
.actionbar {
|
||||
position: sticky; bottom: 0;
|
||||
padding: 12px 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actionbar .btn-primary { flex: 0 1 240px; }
|
||||
/* Room enough to show every action, so More is fully redundant here. */
|
||||
.action-extra { display: inline-flex; }
|
||||
.more-btn { display: none; }
|
||||
|
||||
.sheet {
|
||||
width: min(440px, calc(100% - 32px));
|
||||
@@ -802,9 +939,12 @@ kbd {
|
||||
.admin-table th {
|
||||
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
|
||||
padding: 4px 16px 4px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
/* 16px between columns, so a right-aligned count never touches the
|
||||
left-aligned text beside it ("5" against "16d ago"). */
|
||||
.admin-table td { padding: 10px 16px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.admin-table th:last-child, .admin-table td:last-child { padding-right: 0; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
|
||||
@@ -817,11 +957,26 @@ kbd {
|
||||
Six columns do not fit a phone, so the table scrolls inside its card rather
|
||||
than the page. A heartbeat under a switch with several is indented, the way
|
||||
the escalation ladder indents its levels. */
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
/* A card with a header row (the Team and Admin settings cards) pads itself:
|
||||
plain .card has no padding, so these used to print their text flush against
|
||||
the border with the button jammed in the corner. The divider separates the
|
||||
title row from the content below it. */
|
||||
.card:has(> .card-head) { padding: 20px; }
|
||||
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
|
||||
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
||||
.status-table th, .status-table td { white-space: nowrap; }
|
||||
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||
.status-table .source-row td { border-bottom-style: dashed; }
|
||||
.status-table tr.clickable { cursor: pointer; }
|
||||
.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); }
|
||||
.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; }
|
||||
.switch-facts dt { opacity: .7; }
|
||||
.switch-facts dd { margin: 0; }
|
||||
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
|
||||
@@ -885,23 +1040,32 @@ kbd {
|
||||
too alike down a column to read, so a day carries an initial in that
|
||||
person's colour and the legend underneath says whose. A shift is then a run
|
||||
of one colour, which is the shape the question actually has. */
|
||||
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
|
||||
.rota-grid { padding: 10px 12px 4px; }
|
||||
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
|
||||
.rota-wd {
|
||||
padding-bottom: 4px; text-align: center;
|
||||
padding-bottom: 6px; text-align: center;
|
||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
|
||||
.rota-row + .rota-row { border-top: 1px solid var(--border); }
|
||||
/* A day is a cell that spans both of its row's lines: the number on the first,
|
||||
the shift bar drawn over the second. The bar ignores the pointer, so a tap
|
||||
anywhere in the cell reaches the button. */
|
||||
.rota-day {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
min-height: 52px; padding: 6px 0 8px;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
min-width: 0; padding-top: 2px;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
button.rota-day { cursor: pointer; }
|
||||
button.rota-day:hover { background: var(--surface-2); }
|
||||
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
/* The week number starts each row. Quiet by default, because it is a label
|
||||
first; an owner's tap on it is the second thing it does. */
|
||||
.rota-week {
|
||||
grid-column: 1; grid-row: 1 / span 2;
|
||||
display: grid; place-items: center;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
@@ -909,18 +1073,43 @@ button.rota-day:hover { background: var(--surface-2); }
|
||||
}
|
||||
button.rota-week { cursor: pointer; }
|
||||
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.rota-week.current { color: var(--accent); font-weight: 700; }
|
||||
.rota-week.current { color: var(--text); font-weight: 700; }
|
||||
/* The sheet's row of who holds each day of the week. */
|
||||
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
|
||||
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||
.week-holder.past { opacity: 0.55; }
|
||||
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.rota-day.today { background: var(--accent-soft); }
|
||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||
.rota-day.past { opacity: 0.55; }
|
||||
.rota-num {
|
||||
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
|
||||
text-align: center; line-height: 22px;
|
||||
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Today is a filled number, a marker and not the accent, which means "you can
|
||||
act on this". */
|
||||
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
|
||||
/* The days either side of the month are real days and are drawn, but they
|
||||
belong to the month you are not looking at. */
|
||||
.rota-day.outside { opacity: 0.35; }
|
||||
.rota-day.outside .rota-num { opacity: 0.4; }
|
||||
|
||||
/* A shift: a run of consecutive days held by one person, in that person's
|
||||
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
|
||||
a flat end with a chevron carries on across the row break. */
|
||||
.rota-bar {
|
||||
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
|
||||
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.rota-bar.past { opacity: 0.55; }
|
||||
.rota-bar.outside { opacity: 0.4; }
|
||||
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
|
||||
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
|
||||
.rota-bar.gap {
|
||||
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
|
||||
border: 1px dashed var(--warn); color: var(--warn);
|
||||
}
|
||||
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
|
||||
|
||||
.rota-chip {
|
||||
display: grid; place-items: center;
|
||||
@@ -940,14 +1129,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||
|
||||
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
||||
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
||||
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.rota-note:first-child { margin-top: 0; }
|
||||
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-bulk .stacked-form { margin-top: 4px; }
|
||||
.rota-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
|
||||
}
|
||||
.rota-banner .icon { width: 20px; height: 20px; }
|
||||
.rota-banner.ok { color: var(--ok); }
|
||||
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.rota-foot {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.rota-bulk-form { padding: 0 14px 14px; }
|
||||
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
|
||||
.ladder-level {
|
||||
@@ -960,6 +1153,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
|
||||
/* A target that would not wake anybody says why, in place: it is the reason a
|
||||
level is red, and the thing to go and fix. */
|
||||
/* The escalation ladder: a numbered step per level, the wait between levels as
|
||||
its own line, and what happens after the last one at the bottom. */
|
||||
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
|
||||
.step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||
.step-node {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
border: 2px solid var(--border-strong); background: var(--surface);
|
||||
font-size: 14px; font-weight: 750;
|
||||
}
|
||||
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
|
||||
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
|
||||
.step-node.end { border-style: dashed; color: var(--muted); }
|
||||
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node .icon { width: 16px; height: 16px; }
|
||||
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
|
||||
.step-body { min-width: 0; padding-bottom: 4px; }
|
||||
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
|
||||
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
|
||||
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||||
.step-waiting a { font-weight: 650; }
|
||||
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.step-gap .icon { width: 14px; height: 14px; }
|
||||
.callout {
|
||||
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
|
||||
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
|
||||
.callout strong { color: var(--warn); }
|
||||
.callout p { margin: 2px 0 10px; font-size: 14px; }
|
||||
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.ladder-facts p { margin: 0; }
|
||||
.ladder-facts p + p { margin-top: 4px; }
|
||||
|
||||
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||
|
||||
@@ -1069,3 +1299,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
@media (min-width: 900px) {
|
||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -109,6 +110,9 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||
<span class="nav-label">Stats</span>
|
||||
</a>
|
||||
<!-- Dividers between the groups (Queue, On-call, Alerts, Stats | Team,
|
||||
Admin | Account). Desktop sidebar only; the phone bar has no room. -->
|
||||
<span class="nav-sep" aria-hidden="true"></span>
|
||||
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||
<span class="nav-label">Team</span>
|
||||
@@ -120,9 +124,14 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||
<span class="nav-label">Admin</span>
|
||||
</a>
|
||||
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
|
||||
<span class="nav-sep nav-sep-account" aria-hidden="true"></span>
|
||||
<!-- At the foot of the desktop sidebar, as the signed-in person: app.js
|
||||
fills the avatar and the name from /api/me, and until then it reads
|
||||
"Account". -->
|
||||
<a class="nav-link nav-link-secondary nav-account" href="/more" data-section="more" aria-label="Account">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||
<span class="nav-label">Account</span>
|
||||
<span class="nav-avatar" id="nav-avatar" aria-hidden="true" hidden></span>
|
||||
<span class="nav-label" id="nav-account-label">Account</span>
|
||||
</a>
|
||||
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
|
||||
sheet the old hamburger button did, for the sections the bottom
|
||||
|
||||
@@ -28,15 +28,37 @@ function render() {
|
||||
|
||||
...passwordSection(user, hasPassword),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||
themePicker(),
|
||||
|
||||
h('div', { class: 'only-desktop' },
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||
h('div', { class: 'card' }, shortcuts())),
|
||||
|
||||
h('div', { class: 'page-head' }),
|
||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||
// Wrapped in a div: .btn is inline-flex, and only a block-level element
|
||||
// picks up .view-page > *'s margin:auto centering (see app.css:316).
|
||||
h('div', {}, h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out')),
|
||||
);
|
||||
}
|
||||
|
||||
// System / Light / Dark. Per browser, not per account: it lives in
|
||||
// localStorage (see js/theme.js), the same place the selected team does.
|
||||
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||
|
||||
function themePicker() {
|
||||
const theme = window.terdutTheme;
|
||||
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||
class: 'chip', type: 'button', role: 'radio', text,
|
||||
onclick: () => { theme.set(value); sync(); },
|
||||
}));
|
||||
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||
sync();
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||
}
|
||||
|
||||
// Where this user's pages go. The onboarding checklist's first step sends
|
||||
// people here for it, and until now there was nothing here to send them to:
|
||||
// the topic could only be set with curl or by an administrator.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
@@ -102,13 +102,19 @@ function section() {
|
||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
fadeOnOverflow(nav);
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
@@ -165,7 +171,7 @@ function teamsCard() {
|
||||
function newTeamForm() {
|
||||
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
|
||||
const form = h('form', { class: 'inline-form' }, name,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
@@ -96,7 +96,9 @@ function render() {
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
|
||||
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||
return h('div', {}, h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' })));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
@@ -150,16 +152,19 @@ function identityCard() {
|
||||
// membership looks the way it does, but setting it is the team's own
|
||||
// owner's call, from the Team tab.
|
||||
...(state.auth?.oidc?.enabled ? [
|
||||
fact('OIDC member group', t.oidc_member_group || '—'),
|
||||
fact('OIDC owner group', t.oidc_owner_group || '—'),
|
||||
fact('OIDC member group', t.oidc_member_group || notConfigured()),
|
||||
fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
|
||||
] : []),
|
||||
),
|
||||
form, err, ok,
|
||||
);
|
||||
}
|
||||
|
||||
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
|
||||
|
||||
// value is text or a node.
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
return [h('dt', { text: label }), h('dd', {}, value)];
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
@@ -205,7 +210,7 @@ function membersCard() {
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||
@@ -236,7 +241,7 @@ function invitesCard() {
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create invite' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
@@ -83,7 +83,9 @@ function render() {
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
|
||||
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||
return h('div', {}, h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
@@ -195,7 +197,7 @@ function teamsCard() {
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// incident it belongs to, which is where anything can be done about it.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { age, severityClass, labelSummary } from './format.js';
|
||||
import { h, clear, badge, severityBadge, originChip, emptyState, spinner } from './ui.js';
|
||||
import { age, labelSummary, originOf, ORIGIN_LABEL } from './format.js';
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
||||
@@ -77,7 +77,8 @@ function row(a) {
|
||||
const summary = (a.annotations && a.annotations.summary) || '';
|
||||
const sev = a.labels && a.labels.severity;
|
||||
const labels = labelSummary(Object.fromEntries(
|
||||
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity')));
|
||||
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity' && k !== ORIGIN_LABEL)));
|
||||
const origin = originOf(a.labels);
|
||||
const linked = a.incident_id != null;
|
||||
return h(linked ? 'a' : 'div', {
|
||||
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
|
||||
@@ -86,8 +87,9 @@ function row(a) {
|
||||
h('div', { class: 'row-title', text: a.name }),
|
||||
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
|
||||
h('div', { class: 'row-meta' },
|
||||
origin && originChip(origin),
|
||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
||||
sev && badge(sev, `plain ${severityClass(sev)}`),
|
||||
sev && severityBadge(sev),
|
||||
summary && h('span', { text: summary }),
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
),
|
||||
|
||||
@@ -150,6 +150,8 @@ export const deleteIntegration = (id, integrationID) =>
|
||||
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
||||
export const createDeadmanSwitch = (id, body) =>
|
||||
call('POST', `/teams/${id}/deadman/switches`, { body });
|
||||
export const updateDeadmanSwitch = (id, switchID, body) =>
|
||||
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
|
||||
export const deleteDeadmanSwitch = (id, switchID) =>
|
||||
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
|
||||
import * as adminuser from './adminuser.js';
|
||||
import * as adminteam from './adminteam.js';
|
||||
import * as device from './device.js';
|
||||
import { initial } from './format.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -202,7 +203,8 @@ function updateBadges() {
|
||||
|
||||
const pill = $('open-pill');
|
||||
pill.hidden = false;
|
||||
pill.textContent = open ? `${open} open` : 'All clear';
|
||||
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||
pill.classList.toggle('all-clear', open === 0);
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
@@ -466,7 +468,19 @@ export async function signOut() {
|
||||
showLogin();
|
||||
}
|
||||
|
||||
// The sidebar's Account link shows who is signed in: an avatar and the name,
|
||||
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
|
||||
// screen reader still hears "Account".
|
||||
function renderAccountNav() {
|
||||
const name = state.me?.user?.username;
|
||||
if (!name) return;
|
||||
$('nav-avatar').textContent = initial(name);
|
||||
$('nav-avatar').hidden = false;
|
||||
$('nav-account-label').textContent = name;
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
renderAccountNav();
|
||||
ssoErrorCode = null;
|
||||
$('boot').hidden = true;
|
||||
$('login').hidden = true;
|
||||
|
||||
@@ -117,3 +117,36 @@ export function labelSummary(labels, skip = 'alertname') {
|
||||
export function initial(name) {
|
||||
return (name || '?').trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
// Where an incident or alert came from, for a team with several Kubernetes
|
||||
// clusters (or other origins) behind it: the value of one label, `cluster` by
|
||||
// convention. It comes from Prometheus's externalLabels, so it is on every
|
||||
// alert; an incident carries it only when it is in Alertmanager's group_by,
|
||||
// which is also what keeps two clusters' identical alerts from merging into one
|
||||
// incident (see the README, "Several clusters, one team").
|
||||
export const ORIGIN_LABEL = 'cluster';
|
||||
|
||||
export function originOf(labels) {
|
||||
const v = labels && labels[ORIGIN_LABEL];
|
||||
return v ? String(v) : null;
|
||||
}
|
||||
|
||||
// A stable colour for an origin, from the six-colour rcN palette app.css has,
|
||||
// so "prod-eu" is the same colour on every row without anything being stored.
|
||||
export function originClass(value) {
|
||||
let n = 0;
|
||||
for (const ch of value) n = (n * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return `rc${(n % 6) + 1}`;
|
||||
}
|
||||
|
||||
// The title without its `cluster=...` part, for a row that shows the origin as
|
||||
// a chip: the server puts every grouping label in the title, so the cluster
|
||||
// would otherwise appear twice. Anything that does not look like the server's
|
||||
// "name (k=v, k=v)" shape is returned unchanged.
|
||||
export function titleWithoutOrigin(title, value) {
|
||||
if (!value) return title;
|
||||
const m = title.match(/^(.*?) \((.*)\)$/s);
|
||||
if (!m) return title;
|
||||
const rest = m[2].split(', ').filter((p) => p !== `${ORIGIN_LABEL}=${value}`);
|
||||
return rest.length ? `${m[1]} (${rest.join(', ')})` : m[1];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import * as api from './api.js';
|
||||
import * as poll from './poll.js';
|
||||
import {
|
||||
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
} from './ui.js';
|
||||
import {
|
||||
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
|
||||
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
|
||||
} from './format.js';
|
||||
import { myID, users } from './state.js';
|
||||
import { back } from './app.js';
|
||||
@@ -86,7 +86,6 @@ function render() {
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
h('h1', { class: 'detail-title', text: inc.title }),
|
||||
h('div', { class: 'detail-badges' }, statusBadges()),
|
||||
quickActions(),
|
||||
facts(),
|
||||
groupLabels(),
|
||||
alertsSection(),
|
||||
@@ -100,7 +99,9 @@ function render() {
|
||||
|
||||
function statusBadges() {
|
||||
const out = [];
|
||||
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
|
||||
const origin = originOf(inc.group_labels);
|
||||
if (origin) out.push(originChip(origin));
|
||||
if (inc.severity) out.push(severityBadge(inc.severity));
|
||||
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
||||
@@ -123,6 +124,16 @@ function who(id, name) {
|
||||
return name || 'someone';
|
||||
}
|
||||
|
||||
// ackActorLabel renders whoever acknowledged inc, human or service account —
|
||||
// the two are mutually exclusive (migration 015), and a service account is a
|
||||
// credential, not "you" or "nobody", so it gets its own branch rather than
|
||||
// going through who()'s id-vs-myID() check.
|
||||
function ackActorLabel() {
|
||||
if (inc.acknowledged_by_id != null) return who(inc.acknowledged_by_id, inc.acknowledged_by);
|
||||
if (inc.acknowledged_by_service_account_id != null) return inc.acknowledged_by_service_account || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
function facts() {
|
||||
const rows = [];
|
||||
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
|
||||
@@ -132,17 +143,16 @@ function facts() {
|
||||
// take reading top to bottom to piece together.
|
||||
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
|
||||
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
|
||||
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by)
|
||||
: 'Unassigned';
|
||||
: ackActorLabel() || 'Unassigned';
|
||||
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
|
||||
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
|
||||
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
||||
inc.severity && severityBadge(inc.severity),
|
||||
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
||||
));
|
||||
|
||||
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
||||
if (inc.acknowledged_at) {
|
||||
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
||||
add('Acknowledged', `${ackActorLabel()} · ${when(inc.acknowledged_at)}`);
|
||||
}
|
||||
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
|
||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||
@@ -206,9 +216,28 @@ function alertItem(a) {
|
||||
|
||||
// ---------- timeline ----------
|
||||
|
||||
// actorLabel renders whoever performed ev, human or service account — the
|
||||
// two are mutually exclusive (migration 015). null means the server acted:
|
||||
// ev.user_id == null no longer means that by itself, now that a service
|
||||
// account's events also leave it null.
|
||||
function actorLabel(ev, named = false) {
|
||||
if (ev.user_id != null) return named ? (ev.username || 'someone') : who(ev.user_id, ev.username);
|
||||
if (ev.service_account_id != null) return ev.service_account_name || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
// assignerLabel is actorLabel for an 'assigned' event, whose user_id is the
|
||||
// assignee: the person who made the assignment is in the actor_* fields
|
||||
// (null for assignments from before they were recorded).
|
||||
function assignerLabel(ev, named = false) {
|
||||
if (ev.actor_user_id != null) return named ? (ev.actor_username || 'someone') : who(ev.actor_user_id, ev.actor_username);
|
||||
if (ev.actor_service_account_id != null) return ev.actor_service_account_name || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
// named spells users out instead of "you", for text that leaves this page.
|
||||
function eventText(ev, named = false) {
|
||||
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
|
||||
const person = actorLabel(ev, named);
|
||||
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
||||
const alertName = () => {
|
||||
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
||||
@@ -220,7 +249,12 @@ function eventText(ev, named = false) {
|
||||
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
|
||||
case 'acknowledged': return [strong(person), ' acknowledged'];
|
||||
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
|
||||
case 'assigned': return ['Assigned to ', strong(person)];
|
||||
case 'assigned': {
|
||||
const by = assignerLabel(ev, named);
|
||||
return by ? ['Assigned to ', strong(person), ' by ', strong(by)] : ['Assigned to ', strong(person)];
|
||||
}
|
||||
case 'archived': return [strong(person), ' archived the incident'];
|
||||
case 'unarchived': return [strong(person), ' unarchived the incident'];
|
||||
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||
@@ -306,10 +340,30 @@ function timelineSection() {
|
||||
|
||||
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
||||
|
||||
// One icon per kind of event, so the rail says what happened before the text
|
||||
// does. Anything not listed keeps a plain dot.
|
||||
const EVENT_ICON = {
|
||||
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
|
||||
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
|
||||
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
|
||||
alert_added: 'plus', alert_resolved: 'check',
|
||||
archived: 'archive', unarchived: 'undo',
|
||||
note: 'note', resolution_note: 'note',
|
||||
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
|
||||
};
|
||||
|
||||
// An escalation that ran out of levels, or of anywhere to send the page, is
|
||||
// the one event on the rail that means "nobody is being told": it gets the
|
||||
// warning treatment instead of reading like any other step.
|
||||
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|
||||
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
|
||||
|
||||
function timelineItem(ev) {
|
||||
const mine = isNote(ev) && ev.user_id === myID();
|
||||
return h('li', { class: `tl-item tl-${ev.type}` },
|
||||
h('span', { class: 'tl-dot' }),
|
||||
const problem = isProblem(ev);
|
||||
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
|
||||
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
|
||||
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
|
||||
h('div', { class: 'tl-body' },
|
||||
h('div', { class: 'tl-text' }, eventText(ev)),
|
||||
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
||||
@@ -419,9 +473,8 @@ async function copyIncident() {
|
||||
const isOpen = () => inc.status !== 'resolved';
|
||||
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
||||
|
||||
// primaryAction and secondaryAction are factories, not shared nodes — a
|
||||
// button can only live in one place, and quickActions() below needs its own
|
||||
// copy of the primary one rather than the actionbar's.
|
||||
// primaryAction and secondaryAction are factories, not shared nodes: a button
|
||||
// can only live in one place, and the actionbar is rebuilt on every render.
|
||||
function primaryAction() {
|
||||
if (inc.status === 'triggered') {
|
||||
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||
@@ -440,22 +493,27 @@ function secondaryAction() {
|
||||
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
||||
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
||||
}
|
||||
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
||||
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
|
||||
}
|
||||
|
||||
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
|
||||
// right away, next to the status it responds to. The sticky actionbar below
|
||||
// keeps carrying every action, primary included, for whenever the page has
|
||||
// been scrolled past it.
|
||||
function quickActions() {
|
||||
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
|
||||
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
|
||||
return div;
|
||||
// Desktop has room to show what a phone folds into the More sheet below — see
|
||||
// the .action-extra/.more-btn rules in app.css. Resolved/archived incidents
|
||||
// already say everything via primaryAction()/secondaryAction(), so there is
|
||||
// nothing extra to surface for them.
|
||||
function extraActions() {
|
||||
if (!isOpen()) return [];
|
||||
const out = [
|
||||
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
|
||||
];
|
||||
out.push(inc.status === 'acknowledged'
|
||||
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
|
||||
: h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve…'));
|
||||
return out;
|
||||
}
|
||||
|
||||
function actionBar() {
|
||||
const more = h('button', { class: 'btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
||||
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), more);
|
||||
const more = h('button', { class: 'btn more-btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
||||
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), ...extraActions(), more);
|
||||
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
||||
return bar;
|
||||
}
|
||||
@@ -641,9 +699,9 @@ async function moreMenu() {
|
||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||
} else {
|
||||
// The bar already carries the primary action (Archive/Unarchive) and
|
||||
// Copy, so the sheet adds only what it lacks.
|
||||
items.push(item('note', 'Add note…', addNote));
|
||||
items.push(item('copy', 'Copy incident', copyIncident));
|
||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
||||
}
|
||||
|
||||
const fn = await openSheet(() => [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||
// Read-only for now; the TUI edits the schedule.
|
||||
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||
// button here.
|
||||
//
|
||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||
// team has nothing to choose between. "On call now" is the exception and shows
|
||||
@@ -59,11 +60,17 @@ function render() {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
const team = currentTeam();
|
||||
clear(view(),
|
||||
// The top bar carries the title on a phone; the desktop has none.
|
||||
h('div', { class: 'only-desktop oncall-title' },
|
||||
h('h1', { text: 'On-call' }),
|
||||
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
nowCard(),
|
||||
weekCard(),
|
||||
myShifts(),
|
||||
h('div', { class: 'oncall-grid' },
|
||||
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||
h('div', { class: 'oncall-side' }, myShifts()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,75 +85,73 @@ function nowCard() {
|
||||
const entries = data.now || [];
|
||||
const showTeam = entries.length > 1;
|
||||
if (entries.length === 0) {
|
||||
return h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar none', text: '–' }),
|
||||
h('div', {},
|
||||
h('div', { class: 'now-label', text: 'On call now' }),
|
||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
||||
),
|
||||
);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||
}
|
||||
return h('div', {}, ...entries.map((n) =>
|
||||
h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', {
|
||||
class: 'now-label',
|
||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
||||
}),
|
||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
||||
),
|
||||
)));
|
||||
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||
const until = shiftEnd(n);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||
until && h('div', { class: 'hero-until', text: until }))));
|
||||
}));
|
||||
}
|
||||
|
||||
// Groups the week's 7 days into runs held by the same person (or the same
|
||||
// empty slot) — the week's own version of the consecutive-day grouping
|
||||
// myShifts does for a single person's own dates, below. Seven identical rows
|
||||
// for one person all week collapses to the one bar this way.
|
||||
function weekRuns(byDate) {
|
||||
const runs = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = isoDate(addDays(weekStart, i));
|
||||
const e = byDate.get(date) || null;
|
||||
const uid = e ? e.user_id : null;
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.uid === uid) last.to = date;
|
||||
else runs.push({ uid, entry: e, from: date, to: date });
|
||||
}
|
||||
return runs;
|
||||
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||
// end is read off the rota (the run of consecutive days from today held by the
|
||||
// same person), and that is only loaded for one team. The same midnight
|
||||
// boundary the "Current shift" row below uses.
|
||||
function shiftEnd(n) {
|
||||
const team = currentTeam();
|
||||
if (!team || n.team_id !== team.id) return null;
|
||||
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||
let day = new Date();
|
||||
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||
// Still holding the last day loaded: the shift may run on past it.
|
||||
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||
const end = addDays(parse(isoDate(day)), 1);
|
||||
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||
}
|
||||
|
||||
// A stable colour per person from the six-colour rcN palette. The week page
|
||||
// has no member list to take an index from (team.js does), so the id decides.
|
||||
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||
|
||||
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||
// A hand-over in the middle of the week is visible without reading anything.
|
||||
function weekCard() {
|
||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||
const today = isoDate(new Date());
|
||||
const mine = myID();
|
||||
const days = weekRuns(byDate).map((r) => {
|
||||
const single = r.from === r.to;
|
||||
const cls = [
|
||||
'day',
|
||||
single ? '' : 'range',
|
||||
r.from <= today && today <= r.to ? 'today' : '',
|
||||
r.to < today ? 'past' : '',
|
||||
r.uid === mine ? 'mine' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const label = single
|
||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
||||
: [h('span', {
|
||||
class: 'day-range',
|
||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
||||
})];
|
||||
return h('li', { class: cls },
|
||||
...label,
|
||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
||||
);
|
||||
});
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
const date = isoDate(d);
|
||||
const e = byDate.get(date) || null;
|
||||
if (e) seen.set(e.user_id, e.username);
|
||||
cells.push(h('li', {
|
||||
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||
'aria-current': date === today ? 'date' : null,
|
||||
},
|
||||
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||
}
|
||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
return h('div', { class: 'card week-card' },
|
||||
h('div', { class: 'week-head' },
|
||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
icon('chevronLeft')),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost week-label',
|
||||
@@ -155,12 +160,13 @@ function weekCard() {
|
||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')),
|
||||
),
|
||||
),
|
||||
h('ul', { class: 'card days' }, days),
|
||||
];
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')))),
|
||||
h('ul', { class: 'strip' }, cells),
|
||||
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||
[...seen].map(([id, name]) => h('span', {},
|
||||
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||
id === mine ? `${name} (you)` : name))));
|
||||
}
|
||||
|
||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// The incident queue: filter chips and a list of incident rows.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||
import { h, clear, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
|
||||
import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
|
||||
import { state, myID, onTeamChange } from './state.js';
|
||||
import * as onboarding from './onboarding.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
@@ -118,6 +118,10 @@ function chipCount(id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the first time the chips are drawn: the strip element is the same one
|
||||
// for the life of the page, so one scroll listener and one observer will do.
|
||||
let chipsFade;
|
||||
|
||||
function renderChips() {
|
||||
const el = document.getElementById('queue-filters');
|
||||
const chips = FILTERS.map((f) => {
|
||||
@@ -129,42 +133,15 @@ function renderChips() {
|
||||
'aria-selected': String(f.id === filter),
|
||||
onclick: () => setFilter(f.id),
|
||||
text: f.label,
|
||||
}, count != null && h('span', { class: 'count', text: String(count) }));
|
||||
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
|
||||
});
|
||||
|
||||
// Somebody in one team has nothing to choose between, so the row of team
|
||||
// chips appears only when there is more than one. The default is all of
|
||||
// them: the combined queue is the point.
|
||||
if (state.teams.length > 1) {
|
||||
chips.push(h('span', { class: 'chip-sep' }));
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(state.selectedTeamID == null),
|
||||
onclick: () => setSelectedTeam(null),
|
||||
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||
for (const team of state.teams) {
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(team.id === state.selectedTeamID),
|
||||
onclick: () => setSelectedTeam(team.id),
|
||||
},
|
||||
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
||||
' ' + team.name,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// A scroll hint for the phone-width row, where the chips can run off the
|
||||
// right edge with nothing to suggest there's more; the desktop sidebar
|
||||
// wraps instead of scrolling (see .pane-list .chips), so this fades out
|
||||
// there via CSS rather than being left out here.
|
||||
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
||||
|
||||
// There are no team chips here: the team selector in the sidebar (and in the
|
||||
// phone's top bar) is the one place the team is chosen, and chips for it
|
||||
// would be the same choice offered twice.
|
||||
clear(el, chips);
|
||||
chipsFade ??= fadeOnOverflow(el);
|
||||
chipsFade();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
@@ -194,6 +171,10 @@ function row(inc, index) {
|
||||
const snoozed = isFuture(inc.snoozed_until);
|
||||
const resolved = inc.status === 'resolved';
|
||||
|
||||
// A filter that already says what the status is (Triggered, Resolved,
|
||||
// Archived) would only have every row repeat it.
|
||||
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
|
||||
|
||||
let status;
|
||||
if (resolved) status = badge('Resolved', 'st-resolved');
|
||||
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
|
||||
@@ -214,23 +195,28 @@ function row(inc, index) {
|
||||
// The team is shown only to somebody who is in more than one. For everybody
|
||||
// else it is the same word on every row, which is noise rather than
|
||||
// information.
|
||||
const team = state.teams.length > 1 && inc.team_name
|
||||
// ...and not when the queue is already narrowed to one team: that team is
|
||||
// the same word on every row there too.
|
||||
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
|
||||
? h('span', { class: 'row-team', text: inc.team_name })
|
||||
: null;
|
||||
|
||||
const origin = originOf(inc.group_labels);
|
||||
|
||||
return h('a', {
|
||||
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
|
||||
href: `/incidents/${inc.id}`,
|
||||
'aria-current': inc.id === selected ? 'true' : null,
|
||||
dataset: { index: String(index) },
|
||||
},
|
||||
h('div', { class: 'row-title', text: inc.title }),
|
||||
h('div', { class: 'row-title', text: titleWithoutOrigin(inc.title, origin) }),
|
||||
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
||||
h('div', { class: 'row-meta' },
|
||||
status,
|
||||
origin && originChip(origin),
|
||||
!impliedStatus && status,
|
||||
// The left-border colour alone doesn't say what it means; spell it out
|
||||
// too, same badge the incident detail page uses for severity.
|
||||
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
||||
inc.severity && severityBadge(inc.severity),
|
||||
assignee,
|
||||
team,
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
|
||||
+341
-182
@@ -18,7 +18,7 @@
|
||||
// than no form, but it is not the thing enforcing anything.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
|
||||
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||
|
||||
@@ -168,13 +168,19 @@ function section() {
|
||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
fadeOnOverflow(nav);
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
}
|
||||
|
||||
// Names which team's settings the six sections below belong to. It used to be
|
||||
@@ -229,8 +235,8 @@ function overview() {
|
||||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||||
// which is a rota spelled out one day at a time: the question asked of it is
|
||||
// "who has which stretch", and thirty names down a column is the one shape
|
||||
// that answer cannot be read in. So each day carries a coloured initial
|
||||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||||
// that answer cannot be read in. So a shift is drawn as one bar across its
|
||||
// days with the person's name on it.
|
||||
//
|
||||
// The same month laid out the same way as the on-call page's week, because it
|
||||
// is the same rota — heading and arrows outside the card, days inside it.
|
||||
@@ -269,22 +275,8 @@ function scheduleCard() {
|
||||
const today = isoDate(new Date());
|
||||
const month = monthStart.getMonth();
|
||||
|
||||
// Whose colours to explain, in the order the month meets them. Only the days
|
||||
// of this month count: a name that appears solely in the overhang belongs to
|
||||
// the month next door and would be explaining a chip nobody asked about.
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = addDays(start, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
const inMonth = d.getMonth() === month;
|
||||
// Every row starts with its week number, which is also the way to fill the
|
||||
// whole week at once.
|
||||
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
|
||||
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||||
cells.push(dayCell(d, key, e, inMonth, today));
|
||||
}
|
||||
const rows = [];
|
||||
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
|
||||
|
||||
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
@@ -292,38 +284,104 @@ function scheduleCard() {
|
||||
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
||||
}
|
||||
|
||||
// The range form is the way to fill a whole shift at once, but it is not
|
||||
// what the page is for, so it stays folded away until asked for.
|
||||
let bulk = null;
|
||||
if (isOwner()) {
|
||||
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
|
||||
const toggle = h('button', {
|
||||
class: 'btn', type: 'button', 'aria-expanded': 'false',
|
||||
onclick: () => {
|
||||
form.hidden = !form.hidden;
|
||||
toggle.setAttribute('aria-expanded', String(!form.hidden));
|
||||
},
|
||||
}, icon('calendar'), 'Assign a range of days');
|
||||
bulk = [
|
||||
h('div', { class: 'rota-foot' },
|
||||
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
|
||||
form,
|
||||
];
|
||||
}
|
||||
|
||||
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
h('h2', { text: 'On-call rota' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
class: 'btn btn-icon week-arrow', type: 'button',
|
||||
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||||
}, icon('chevronLeft')),
|
||||
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost label', type: 'button',
|
||||
title: 'Back to this month',
|
||||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||
text: monthFmt.format(monthStart),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
class: 'btn btn-icon week-arrow', type: 'button',
|
||||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||||
}, icon('chevronRight')),
|
||||
h('button', {
|
||||
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
|
||||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||
}),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'rota-grid' }, heads, cells),
|
||||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||||
// The range form is the way to fill a whole shift at once, but it is not
|
||||
// what the page is for, so it stays folded away under the month it edits.
|
||||
isOwner() && h('details', { class: 'rota-bulk' },
|
||||
h('summary', { text: 'Assign a range of days' }),
|
||||
assignForm()),
|
||||
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
|
||||
coverNote(byDate),
|
||||
bulk,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
|
||||
// shifts drawn over the days as bars. A bar is a run of consecutive days held
|
||||
// by the same person, so a week handed to one person is one bar with one name
|
||||
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
|
||||
function weekRow(monday, byDate, today, month) {
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
|
||||
const keys = days.map(isoDate);
|
||||
const entries = keys.map((k) => byDate.get(k) || null);
|
||||
const uid = (i) => (entries[i] ? entries[i].user_id : null);
|
||||
const before = byDate.get(isoDate(addDays(monday, -1)));
|
||||
const after = byDate.get(isoDate(addDays(monday, 7)));
|
||||
|
||||
const cells = [weekCell(monday, byDate, today)];
|
||||
days.forEach((d, i) => {
|
||||
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
|
||||
cell.style.gridColumn = String(i + 2);
|
||||
cells.push(cell);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 7;) {
|
||||
let j = i;
|
||||
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
|
||||
const entry = entries[i];
|
||||
// A flat end says the shift carries on past this row, not that it stops.
|
||||
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
|
||||
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
|
||||
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
|
||||
i = j + 1;
|
||||
}
|
||||
return h('div', { class: 'rota-row' }, cells);
|
||||
}
|
||||
|
||||
function shiftBar(entry, i, j, o) {
|
||||
const span = j - i + 1;
|
||||
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
|
||||
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
|
||||
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
|
||||
.filter(Boolean).join(' ');
|
||||
// One day is too narrow for a name on a phone; the initial stands in and the
|
||||
// full name is in the day's own label underneath.
|
||||
const name = entry
|
||||
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
|
||||
: (span >= 3 ? 'Nobody on call' : '');
|
||||
const bar = h('div', {
|
||||
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
|
||||
text: entry && span === 1 ? initial(entry.username) : name,
|
||||
});
|
||||
bar.style.gridColumn = `${i + 2} / span ${span}`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
// The ISO week number at the start of a row. For an owner it is a button: one
|
||||
// tap fills the week, which is the way a rota is usually handed out — a person
|
||||
// takes a week, not seven separate days.
|
||||
@@ -342,15 +400,10 @@ function weekCell(monday, byDate, today) {
|
||||
}
|
||||
|
||||
function dayCell(d, key, e, inMonth, today) {
|
||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
|
||||
.filter(Boolean).join(' ');
|
||||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||||
const body = [
|
||||
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||||
e
|
||||
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||||
: h('span', { class: 'rota-chip none' }),
|
||||
];
|
||||
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
|
||||
// A member sees the same grid without the affordance, the way every other
|
||||
// control on this page is hidden rather than shown and refused.
|
||||
return isOwner()
|
||||
@@ -369,17 +422,7 @@ function colorClass(userID) {
|
||||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||||
}
|
||||
|
||||
function legend(seen) {
|
||||
if (!seen.size) return null;
|
||||
return h('div', { class: 'rota-legend' },
|
||||
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||||
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||||
h('span', { text: name }),
|
||||
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||||
)));
|
||||
}
|
||||
|
||||
// The gap count, which is the one thing the grid states only by omission. Days
|
||||
// The gap count, which is the one thing the bars state only by omission. Days
|
||||
// already past are not counted: an empty Tuesday last week is history, not a
|
||||
// hole somebody still has to fill.
|
||||
function coverNote(byDate) {
|
||||
@@ -390,10 +433,14 @@ function coverNote(byDate) {
|
||||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||||
if (key >= today && !byDate.has(key)) gaps++;
|
||||
}
|
||||
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||||
return h('p', { class: 'rota-note' },
|
||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||
' left this month with nobody on call.');
|
||||
if (gaps === 0) {
|
||||
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
|
||||
h('span', { text: 'Every day left this month has somebody on call.' }));
|
||||
}
|
||||
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
|
||||
h('span', {},
|
||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||
' left this month with nobody on call.'));
|
||||
}
|
||||
|
||||
// One week, in the sheet: who holds each day of it, and one person to put on
|
||||
@@ -518,7 +565,7 @@ function assignForm() {
|
||||
// API enforces: a plain assignment that silently moved a shift would move
|
||||
// who gets paged without telling either of them.
|
||||
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Assign' }));
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -562,23 +609,49 @@ function escalationCard() {
|
||||
const esc = data.escalation || {};
|
||||
const levels = esc.levels || [];
|
||||
|
||||
const rows = levels.map((l) => h('tr', {},
|
||||
h('td', {}, h('strong', { text: `Level ${l.position}` })),
|
||||
h('td', {}, levelBadge(l.status)),
|
||||
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
|
||||
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
|
||||
h('td', { class: 'small' }, l.waiting?.length
|
||||
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
|
||||
: h('span', { class: 'muted', text: '—' })),
|
||||
));
|
||||
// The ladder as steps, top to bottom: each level, the wait before the next,
|
||||
// and what happens when the last one is not answered. The wait belongs to the
|
||||
// level above it ("then after 5m"), which is how the API states it.
|
||||
const steps = [];
|
||||
levels.forEach((l) => {
|
||||
steps.push(h('li', { class: 'step' },
|
||||
h('div', { class: 'step-rail' },
|
||||
h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
|
||||
h('span', { class: 'step-line' })),
|
||||
h('div', { class: 'step-body' },
|
||||
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
|
||||
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
|
||||
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
|
||||
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
|
||||
steps.push(h('li', { class: 'step step-wait' },
|
||||
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
|
||||
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
|
||||
});
|
||||
|
||||
if (levels.length) {
|
||||
steps.push(h('li', { class: 'step' },
|
||||
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
|
||||
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
|
||||
h('div', { class: 'step-body' },
|
||||
h('div', { class: 'step-top' },
|
||||
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
|
||||
esc.fallback_topic
|
||||
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
|
||||
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
|
||||
h('div', {},
|
||||
h('strong', { text: 'No fallback topic' }),
|
||||
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
|
||||
}, icon('plus'), 'Add fallback'))))));
|
||||
}
|
||||
|
||||
const facts = [];
|
||||
if (levels.length) {
|
||||
const n = esc.repeat_count || 0;
|
||||
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
|
||||
facts.push(esc.fallback_topic
|
||||
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
|
||||
: 'No fallback topic: after the last level the chain just ends.');
|
||||
facts.push(n
|
||||
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
|
||||
: 'Nothing repeats: the ladder runs once.');
|
||||
facts.push(esc.last_escalated_at
|
||||
? ['Last escalated ',
|
||||
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
||||
@@ -590,23 +663,18 @@ function escalationCard() {
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Escalation' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', onclick: openLadderEditor,
|
||||
class: 'btn btn-primary', type: 'button', onclick: () => openLadderEditor(),
|
||||
text: levels.length ? 'Edit ladder' : 'Set up ladder',
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||
levels.length
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
|
||||
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
|
||||
h('tbody', {}, rows)))
|
||||
? h('ol', { class: 'ladder' }, steps)
|
||||
: h('p', { class: 'muted' },
|
||||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||
'reminder interval and nobody else is woken.'),
|
||||
...facts.map((f) => h('p', { class: 'muted small' }, f)),
|
||||
facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -614,7 +682,7 @@ function escalationCard() {
|
||||
// it wholesale: the levels are an order, and patching one rung would leave the
|
||||
// numbering of the others undecided. The draft lives in the sheet, so a poll of
|
||||
// the page underneath cannot throw away half an edit.
|
||||
function openLadderEditor() {
|
||||
function openLadderEditor({ focusFallback = false } = {}) {
|
||||
const esc = data.escalation || {};
|
||||
const draft = {
|
||||
repeat_count: esc.repeat_count || 0,
|
||||
@@ -626,6 +694,7 @@ function openLadderEditor() {
|
||||
};
|
||||
|
||||
const body = h('div', { class: 'ladder-editor' });
|
||||
let fallbackInput = null;
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
const paint = () => {
|
||||
@@ -665,7 +734,7 @@ function openLadderEditor() {
|
||||
value: String(draft.repeat_count),
|
||||
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||||
});
|
||||
const fallback = h('input', {
|
||||
const fallback = fallbackInput = h('input', {
|
||||
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||
});
|
||||
@@ -696,6 +765,7 @@ function openLadderEditor() {
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
save),
|
||||
]);
|
||||
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
|
||||
}
|
||||
|
||||
function targetRow(level, target, index, repaint) {
|
||||
@@ -735,42 +805,25 @@ function minutesInput(seconds, onChange) {
|
||||
// --- integrations ----------------------------------------------------------
|
||||
|
||||
function integrationsCard() {
|
||||
const rows = (data.integrations || []).map((i) =>
|
||||
h('tr', {},
|
||||
h('td', {}, sourceBadge(i.status)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: i.name }),
|
||||
h('div', { class: 'muted small', text: i.kind })),
|
||||
// When the key last posted, and when an alert last arrived on it. They
|
||||
// differ: a payload with nothing usable in it stamps only the first.
|
||||
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
|
||||
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
|
||||
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
||||
text: String(i.alerts_24h ?? 0) }),
|
||||
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Revoke ${i.name}?`,
|
||||
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteIntegration(teamID, i.id));
|
||||
},
|
||||
}))),
|
||||
));
|
||||
const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
|
||||
h('td', {}, sourceBadge(i.status)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: i.name }),
|
||||
h('div', { class: 'muted small', text: i.kind })),
|
||||
// When the key last posted, and when an alert last arrived on it. They
|
||||
// differ: a payload with nothing usable in it stamps only the first.
|
||||
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
|
||||
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
|
||||
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
||||
text: String(i.alerts_24h ?? 0) }),
|
||||
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||
), () => openSourceDetail(i)));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Alert sources' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
|
||||
class: 'btn btn-primary', type: 'button', text: 'New source', onclick: openNewSource,
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Alerts arrive on an integration key, which says both that the sender may ',
|
||||
@@ -780,9 +833,9 @@ function integrationsCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Source' }),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
|
||||
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
|
||||
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))),
|
||||
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||||
);
|
||||
@@ -844,6 +897,34 @@ function openNameSheet({ title, submit, value, run }) {
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
|
||||
}
|
||||
|
||||
function openSourceDetail(i) {
|
||||
openDetailSheet(i.name, [
|
||||
sheetFact('Status', sourceBadge(i.status)),
|
||||
sheetFact('Kind', i.kind),
|
||||
sheetFact('Last webhook', timeCell(i.last_used_at)),
|
||||
sheetFact('Last alert', timeCell(i.last_alert_at)),
|
||||
sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)),
|
||||
sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Rename',
|
||||
onclick: () => { closeSheet(); openRenameSource(i); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Revoke',
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Revoke ${i.name}?`,
|
||||
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteIntegration(teamID, i.id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function openNewSource() {
|
||||
openNameSheet({
|
||||
title: 'New source', submit: 'Add source', value: '',
|
||||
@@ -899,6 +980,34 @@ const triggeredCell = (iso, incidentID) => {
|
||||
: h('span', { title: when(iso), text: ago(iso) });
|
||||
};
|
||||
|
||||
// A list row that opens its details: the list is for finding the thing, the
|
||||
// sheet it opens is where the buttons are. Links inside the row keep working.
|
||||
function clickableRow(tr, open) {
|
||||
tr.classList.add('clickable');
|
||||
tr.tabIndex = 0;
|
||||
tr.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('a')) open();
|
||||
});
|
||||
tr.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && e.target === tr) open();
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Label/value rows for a details sheet, and the sheet itself: facts above, then
|
||||
// Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out.
|
||||
const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)];
|
||||
|
||||
function openDetailSheet(title, facts, ...actions) {
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: title }),
|
||||
h('dl', { class: 'switch-facts' }, facts),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }),
|
||||
...actions),
|
||||
]);
|
||||
}
|
||||
|
||||
function switchRows(sw) {
|
||||
const main = h('tr', {},
|
||||
h('td', {}, switchBadge(sw.status)),
|
||||
@@ -908,19 +1017,8 @@ function switchRows(sw) {
|
||||
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
|
||||
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||||
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
|
||||
h('td', {}, isOwner() && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${sw.name}?`,
|
||||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||||
},
|
||||
})),
|
||||
);
|
||||
clickableRow(main, () => openSwitchDetail(sw));
|
||||
|
||||
// One heartbeat is the switch's own times; several are worth telling apart,
|
||||
// since a live cluster must not hide a dead one.
|
||||
@@ -935,7 +1033,7 @@ function switchRows(sw) {
|
||||
&& h('code', { class: 'small', text: src.fingerprint })),
|
||||
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
|
||||
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
|
||||
h('td'), h('td')))
|
||||
h('td')))
|
||||
: [];
|
||||
return [main, ...sources];
|
||||
}
|
||||
@@ -947,7 +1045,7 @@ function deadmanCard() {
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Dead man’s switches' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
|
||||
class: 'btn btn-primary', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||||
@@ -956,9 +1054,9 @@ function deadmanCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
|
||||
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
|
||||
h('th', { text: 'Silent after' }), h('th'))),
|
||||
h('th', { text: 'Silent after' }))),
|
||||
h('tbody', {}, switches.flatMap(switchRows))))
|
||||
: h('p', { class: 'muted', text: 'Nothing watched.' }),
|
||||
);
|
||||
@@ -966,16 +1064,22 @@ function deadmanCard() {
|
||||
|
||||
// The form lives in the sheet, not on the page: most visits are to look at the
|
||||
// list, and a form that is always open is the page this replaced.
|
||||
function openNewSwitch() {
|
||||
function openSwitchForm(existing) {
|
||||
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
|
||||
const matcher = h('input', {
|
||||
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
||||
});
|
||||
const timeout = h('input', {
|
||||
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
|
||||
type: 'number', min: '1', step: 'any', value: '15', class: 'setting-value', required: true,
|
||||
});
|
||||
const severity = h('select', {},
|
||||
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
|
||||
if (existing) {
|
||||
name.value = existing.name;
|
||||
matcher.value = existing.matcher;
|
||||
timeout.value = String(existing.timeout_seconds / 60);
|
||||
severity.value = existing.severity;
|
||||
}
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
@@ -990,17 +1094,20 @@ function openNewSwitch() {
|
||||
problem,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: existing ? 'Save' : 'Add switch' })));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.createDeadmanSwitch(teamID, {
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
matcher: matcher.value.trim(),
|
||||
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
||||
severity: severity.value,
|
||||
});
|
||||
};
|
||||
await (existing
|
||||
? api.updateDeadmanSwitch(teamID, existing.id, body)
|
||||
: api.createDeadmanSwitch(teamID, body));
|
||||
} catch (err) {
|
||||
problem.textContent = err.message;
|
||||
problem.hidden = false;
|
||||
@@ -1010,7 +1117,47 @@ function openNewSwitch() {
|
||||
refresh();
|
||||
});
|
||||
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]);
|
||||
}
|
||||
|
||||
// What the list row has no room for, and where Edit and Delete live.
|
||||
function openSwitchDetail(sw) {
|
||||
const sources = sw.sources.length > 1
|
||||
? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' },
|
||||
switchBadge(src.status),
|
||||
...Object.entries(src.labels || {})
|
||||
.filter(([k]) => k !== 'alertname')
|
||||
.map(([k, v]) => labelChip(k, v)),
|
||||
h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))]
|
||||
: [];
|
||||
|
||||
openDetailSheet(sw.name, [
|
||||
sheetFact('Status', switchBadge(sw.status)),
|
||||
sheetFact('Matcher', h('code', { text: sw.matcher })),
|
||||
sheetFact('Silent after', duration(sw.timeout_seconds * 1000)),
|
||||
sheetFact('Severity', sw.severity),
|
||||
sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)),
|
||||
sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||||
sources,
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit',
|
||||
onclick: () => { closeSheet(); openSwitchForm(sw); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Delete',
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Remove ${sw.name}?`,
|
||||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
@@ -1050,23 +1197,27 @@ function shiftCell(m) {
|
||||
function oidcGroupsCard() {
|
||||
if (!state.auth?.oidc?.enabled) return null;
|
||||
const g = data.oidcGroups || { member_group: '', owner_group: '' };
|
||||
// With nothing set, Edit is the one thing to do here, so it leads.
|
||||
const unset = !g.member_group && !g.owner_group;
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Single sign-on' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
|
||||
class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Members of the group below are added to this team automatically at ',
|
||||
'sign-in; members of the owner group become owners. Leave a field ',
|
||||
'blank to grant nothing this way.'),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Member group', g.member_group || '—'),
|
||||
fact('Owner group', g.owner_group || '—'),
|
||||
fact('Member group', g.member_group || notConfigured()),
|
||||
fact('Owner group', g.owner_group || notConfigured()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
@@ -1114,51 +1265,24 @@ function membersCard() {
|
||||
const members = data.members || [];
|
||||
const owners = members.filter((m) => m.role === 'owner').length;
|
||||
|
||||
const rows = members.map((m) => {
|
||||
const lastOwner = m.role === 'owner' && owners === 1;
|
||||
return h('tr', {},
|
||||
h('td', {}, memberBadge(m)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: m.username }),
|
||||
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
|
||||
m.problem && h('div', { class: 'target-problem', text: m.problem })),
|
||||
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'muted small' }, shiftCell(m)),
|
||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||
h('td', { class: 'muted small' },
|
||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Edit',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => openEditMember(m),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner || m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED
|
||||
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
text: 'They lose access to this team. Rota days already assigned to them are not '
|
||||
+ 'changed, so reassign those from the Rota tab.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.removeTeamMember(teamID, m.user_id));
|
||||
},
|
||||
}))),
|
||||
);
|
||||
});
|
||||
const rows = members.map((m) => clickableRow(h('tr', {},
|
||||
h('td', {}, memberBadge(m)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: m.username }),
|
||||
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
|
||||
m.problem && h('div', { class: 'target-problem', text: m.problem })),
|
||||
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'muted small' }, shiftCell(m)),
|
||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||
h('td', { class: 'muted small' },
|
||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
), () => openMemberDetail(m, owners)));
|
||||
|
||||
return [oidcGroupsCard(), h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Members' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
|
||||
class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember,
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Owners set up the team; members work its incidents. Somebody who can’t be ',
|
||||
@@ -1167,14 +1291,49 @@ function membersCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
|
||||
h('th'))),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
||||
)];
|
||||
}
|
||||
|
||||
function openMemberDetail(m, owners) {
|
||||
const lastOwner = m.role === 'owner' && owners === 1;
|
||||
openDetailSheet(m.username, [
|
||||
sheetFact('Status', memberBadge(m)),
|
||||
sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())),
|
||||
sheetFact('Rota', shiftCell(m)),
|
||||
sheetFact('Last active', timeCell(m.last_active_at)),
|
||||
sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })),
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => { closeSheet(); openEditMember(m); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner || m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED
|
||||
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
text: 'They lose access to this team. Rota days already assigned to them are not '
|
||||
+ 'changed, so reassign those from the Rota tab.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.removeTeamMember(teamID, m.user_id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// One sheet for both jobs a member's row has: who, and as what. Adding is
|
||||
// choosing a person and a role; editing is the same with the person fixed. The
|
||||
// API is one call either way — POST upserts the role.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Theme override: System (no attribute, the OS decides), Light or Dark.
|
||||
//
|
||||
// A classic script loaded from <head>, not a module, so the saved choice is on
|
||||
// <html> before the first paint and a Dark user on a light OS never sees a
|
||||
// flash. The CSP allows no inline script, hence a file of its own. Account
|
||||
// (account.js) reads and writes the choice through window.terdutTheme.
|
||||
(function () {
|
||||
var KEY = 'terdut.theme';
|
||||
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
|
||||
|
||||
function get() {
|
||||
try {
|
||||
var v = localStorage.getItem(KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'system';
|
||||
} catch (e) {
|
||||
return 'system'; // storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// The browser chrome colour follows the choice too; the two <meta>s are
|
||||
// media-keyed to the OS, so a forced theme sets both to the same colour.
|
||||
function apply(theme) {
|
||||
var root = document.documentElement;
|
||||
if (theme === 'system') root.removeAttribute('data-theme');
|
||||
else root.setAttribute('data-theme', theme);
|
||||
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
for (var i = 0; i < metas.length; i++) {
|
||||
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
|
||||
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
|
||||
}
|
||||
}
|
||||
|
||||
function set(theme) {
|
||||
try {
|
||||
if (theme === 'system') localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, theme);
|
||||
} catch (e) {
|
||||
/* storage unavailable: applies for this page view only */
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
window.terdutTheme = { get: get, set: set };
|
||||
apply(get());
|
||||
})();
|
||||
@@ -1,5 +1,7 @@
|
||||
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||||
|
||||
import { severityClass, originClass } from './format.js';
|
||||
|
||||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||||
export function h(tag, attrs = {}, ...children) {
|
||||
@@ -30,8 +32,29 @@ export function clear(el, ...children) {
|
||||
return el;
|
||||
}
|
||||
|
||||
// Marks a horizontally scrolling strip with data-more while there is more of it
|
||||
// to the right, which the CSS turns into a fade on that edge. The fade is a
|
||||
// mask on the strip itself, so it stays put at the edge of the screen: a fade
|
||||
// drawn as a child of the scroller scrolls away with the content. Returns the
|
||||
// update function, for a strip whose contents change under it.
|
||||
export function fadeOnOverflow(el) {
|
||||
const update = () => {
|
||||
el.toggleAttribute('data-more', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
};
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(el);
|
||||
update();
|
||||
return update;
|
||||
}
|
||||
|
||||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||||
const ICONS = {
|
||||
server: ['rect:3,4,18,6,2', 'rect:3,14,18,6,2', 'M7 7h.01', 'M7 17h.01'],
|
||||
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
|
||||
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
|
||||
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
|
||||
arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'],
|
||||
plus: ['M12 5v14M5 12h14'],
|
||||
back: ['M15 18l-6-6 6-6'],
|
||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||||
@@ -171,6 +194,23 @@ export function badge(text, cls = '') {
|
||||
return h('span', { class: `badge ${cls}`, text });
|
||||
}
|
||||
|
||||
// Where it came from (the cluster): a chip in that origin's colour, with a
|
||||
// server icon so it reads as a place and not as a status.
|
||||
export function originChip(value) {
|
||||
return h('span', { class: `origin-chip ${originClass(value)}`, title: `Origin: ${value}` },
|
||||
icon('server', 'icon badge-icon'), value);
|
||||
}
|
||||
|
||||
// The severity as a badge with a shape as well as a colour: a triangle for
|
||||
// critical, a circle with ! for warning, a circle with i for anything else, so
|
||||
// the coloured bar and badge are not the only thing that says it.
|
||||
const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' };
|
||||
export function severityBadge(severity) {
|
||||
const cls = severityClass(severity);
|
||||
return h('span', { class: `badge plain ${cls}` },
|
||||
icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity);
|
||||
}
|
||||
|
||||
// Access granted by the identity provider's groups. The server refuses to edit
|
||||
// it by hand (it would be undone at the next sign-in), so the controls that
|
||||
// would try are disabled with this as their reason.
|
||||
|
||||
Reference in New Issue
Block a user