Commit Graph

7 Commits

Author SHA1 Message Date
Niklas Ye e616c82646 Show team members as a list, with who is on call and who cannot be paged
Team -> Members was a two-column table and an inline add form. It is now
a table in the style of Switches, Sources and Escalation: a status
badge, the member, their role, their next rota day, when they were last
active and when they joined. Adding a member and changing a role moved
into sheets, and removing one asks first.

The badge is the one that matters at 03:00: On call if the rota has them
today, Reachable if they have an ntfy topic, and Can't be paged when a
page to them would go nowhere -- no topic, or a disabled account -- with
the reason under their name. Not being pageable wins over being on call,
since an on-call person nobody can reach is the case worth seeing before
an incident finds it. The rules are the notifier's own. The topic itself
is never in the response, only whether one is set.

Last active is the newer of a member's newest session and API-key use,
and is shown to every member of the team like the rest of the list.
Rota days are UTC dates, and the page formats them as such so a day
cannot show up as the one before.

Removing a member leaves the rota days already assigned to them alone,
which the confirm says, so they are reassigned from the Rota tab rather
than silently dropped.

Demoting the last owner is now refused with 409, as removing them
already was: it was the same outcome by another route, a team with
nobody who can edit it.

API: GET /members gains status, on_call, next_shift, pageable, problem
and last_active_at; additive, no migration, and terdut-tui needs
nothing. POST /members answers 409 for the last-owner demotion.
2026-09-26 09:04:54 +02:00
Niklas Ye d675f8ec9b List alert sources with their status and last arrival on Team -> Sources
Like Team -> Switches, the page is now a table: a status badge (Active
if the key posted within a day, Quiet if it has but not lately, Never
used), when it last posted a webhook, when an alert last arrived on it,
how many distinct alerts it refreshed in the last 24 hours, and when it
was created. Adding a source moved into a "New source" sheet, and owners
can rename one from its row.

"Last alert" and the count needed alerts to remember which source they
came in on, which they never did, so migration 010 adds
alerts.integration_id and every accepted payload stamps it. Last sender
wins when two sources post the same fingerprint. It is not backfilled: a
NULL says "before this was recorded" rather than guessing, and it heals
by itself as Alertmanager re-sends each alert every repeat_interval.
Revoking a source keeps its alerts, unattributed.

Last webhook and last alert are separate on purpose: a payload with
nothing usable in it stamps the first and not the second. The Quiet
threshold is a fixed day, a colour and not an alarm, since silence that
should page is what dead man's switches are for.

The counts are indexed subqueries (alerts_integration_idx) rather than a
join, which would read every alert a source ever delivered.

API: the integrations list gains status, last_alert_at and alerts_24h,
and PATCH /api/teams/{id}/integrations/{id} renames. Both are additive;
terdut-tui needs nothing.
2026-09-26 07:52:07 +02:00
Niklas Ye f3918b863c List dead man's switches with their status on Team -> Switches
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.

That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.

The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.

Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.

API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
2026-09-25 23:40:55 +02:00
Niklas Ye ac9af8e4f5 Manage a person's account and teams from one page
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.

A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.

Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.

One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.

No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.

So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.

TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.

The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:42 +02:00
Niklas Ye 7c87ae2af8 Remove the unauthenticated webhook and the SQLite migration script
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m54s
Both existed to carry an upgrade across, and both upgrades are done.

/api/alertmanager/webhook took no credential at all: anything able to
reach the port could open an incident for anybody. v0.12.0 kept it,
deprecated, so the teams release did not stop delivery while the
Alertmanager config was edited, and logged a line per payload asking to
be moved. The cluster's Alertmanager now posts on an integration key --
verified in the log, every two minutes, with no deprecation line since
the rollout -- so the door can be shut rather than left ajar until
somebody remembers. A sender still posting there gets the JSON 404 every
unknown /api path gets.

The tests move with it, which they should have done anyway: the harness
mints an integration key for the default team and posts on that, so they
exercise the path production uses rather than one only they still used.

scripts/sqlite-to-postgres.go goes the same way. It was written to be
temporary, it was the last thing needing modernc.org/sqlite, and this
install migrated on 2026-09-20. `go mod tidy` drops the driver and its
six transitive dependencies with it; the module graph is now chi, pgx,
pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out
of the v0.12.0 tag, which the README now says.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:11:30 +02:00
Niklas Ye 74359c72ab 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
2026-09-20 15:18:22 +02:00
Niklas Ye a4fbd60441 Scope everything to a team, and route alerts by integration key
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m49s
The core of #4, and what #1 is for: terdut stops being one shared space.
A team owns its incidents, alerts, schedule and integrations; a user sees
exactly the teams they are in. Everything that existed moves into one
Default team and every existing user becomes an owner of it, so the
upgrade is a no-op for the people using it.

Ingestion is the load-bearing half. An alert arrives on a team's
integration key, and the key is both the credential and the routing: it
says that the sender may post, and which team the alerts belong to. That
also closes the unauthenticated webhook -- the old path stays for one
release, deprecated and routed to the oldest team, so an upgrade does not
stop delivering while somebody edits the Alertmanager config.

Scoping is enforced in as few places as possible, because the failure
mode is silent. serveAs loads the caller's memberships once; list queries
carry `team_id = ANY(...)`; and every incident route goes through
incidentIDParam, which now parses the id AND checks the team in the same
call, so a new handler cannot remember the first half and forget the
second. Anything in another team is 404, never 403: whether an incident
exists is that team's business.

Two bugs this found, both of which would have been silent:

  * upsertAlerts decided "is this a new occurrence" by looking up the
    fingerprint alone. Across teams that made team B's first alert look
    like a re-send of team A's, so it opened no incident at all. The
    lookups are keyed on (team_id, fingerprint) now, as the index is.

  * Every uniqueness rule was written for one tenant. Two teams watching
    two clusters legitimately see the same fingerprint, the same
    groupKey, and want somebody on call on the same day; all three
    constraints move to include team_id.

Roles inside a team are separate from the system administrator flag: an
owner configures the team, a member works its incidents, and an admin is
NOT implicitly in every team -- administration is about accounts, not
about reading other people's incidents. An admin can still repair a team
whose owner has left, which is why requireTeamOwner lets them through.

A shift can only be given to somebody in the team. Paging a person who
cannot open the incident is worse than paging nobody.

The UI is updated only as far as keeping it working: it loads the
viewer's teams with the session and uses the first one, since nobody has
a second yet. "On call now" shows every team the viewer is in, named only
when there is more than one, so the common case reads exactly as before.
The team switcher, badges and per-team settings pages are the next step.

Breaking for API clients: the schedule endpoints moved under the team,
and /api/schedule/current returns an array rather than an object or a
404. terdut-tui will need a version for that.

Per-team dead-man configuration is deliberately not here. A heartbeat's
incident already opens in the team whose key received it, which is the
part that matters for isolation; moving the matchers out of env into
per-team rows is a change to how deadman.go is configured rather than to
who sees what.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 13:36:24 +02:00