Give each team its own dead man's switches, and the UI a team to show
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 1m57s

The rest of #4. Two halves that belong together because they are the
same sentence from opposite ends: a team decides which of its alerts are
heartbeats, and the UI has to be able to say which team it is talking
about.

Switches were three environment variables, which made them one setting
for the whole install. That was the last piece of the alerting path a
team could not control: it could take its own alerts on its own key and
still not say which of them were heartbeats, or how long a silence had
to last. They are a row per team now, edited by an owner through
PUT /api/teams/{teamID}/deadman, and the sweeper runs each team against
its own matchers, timeout and severity.

The environment variables become the starting point rather than the
setting. Every team without a configuration is seeded from them at
startup, so an upgrade keeps watching exactly what it was watching, and
SeedDeadmanConfigs never overwrites -- a redeploy must not put the
environment's value back over an owner's edit. A team created later
watches nothing until somebody says otherwise: inheriting an
install-wide heartbeat would page a new team about a source it has never
heard of, and a switch nobody chose is the kind that gets muted rather
than fixed.

A matcher string with no alertname in it is refused at the door instead
of stored. Storing it would produce a switch that watches nothing
silently, which is the exact failure the feature exists to prevent.

NewRouter and Sweep lose their DeadmanConfig parameter -- there is no
longer one answer to hand them. The type stays, because parsing a
matcher string is still parsing a matcher string.

The UI side: rows in the queue carry a team badge, the filter row gains
a team chip per team, and "on call now" shows one card per team. All
three appear only when the viewer is in more than one team -- otherwise
they are the same word repeated down a list, which is noise rather than
information, and the single-team install reads exactly as it did before
teams existed.

Verified against a live two-team server as well as in tests: the
combined queue labelled by team, the team_id filter, a heartbeat that is
a heartbeat in one team and an ordinary alert in another, and a new
team's switches starting empty while the upgraded team keeps the
environment's.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-20 15:18:22 +02:00
parent a4fbd60441
commit 74359c72ab
15 changed files with 579 additions and 58 deletions
+12 -7
View File
@@ -9,11 +9,11 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
// NewRouter builds the HTTP surface. notify and deadman are passed through to
// the webhook, the only handler that has to decide where a new incident's page
// goes and which arriving alerts are heartbeats rather than problems. A zero
// notify disables notifications; a zero deadman disables dead man's switches.
func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Handler {
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
// the only handler that has to decide where a new incident's page goes; a zero
// notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -31,13 +31,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
// Alert ingestion. The key in the path says both that the sender may post
// and which team the alerts belong to, which is why it needs no session.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify, deadman))
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// DEPRECATED, and unauthenticated: anything that can reach the port can
// open an incident here. Kept for one release so an upgrade does not stop
// delivering while the Alertmanager config is edited; it routes everything
// to the oldest team. Remove it once senders carry a key.
r.Post("/api/alertmanager/webhook", handleLegacyWebhook(db, notify, deadman))
r.Post("/api/alertmanager/webhook", handleLegacyWebhook(db, notify))
// Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key.
@@ -101,6 +101,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
// A team's own dead man's switches: which of its alerts are heartbeats,
// and how long a silence has to last before somebody is paged.
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db))
// Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))