diff --git a/README.md b/README.md
index 24d991d..9787731 100644
--- a/README.md
+++ b/README.md
@@ -155,12 +155,25 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g
## Configuration
+Two kinds of setting, split by who changes them and how often.
+
+**Where the server is plugged in** stays in the environment: the listen address,
+the database DSN, the ntfy URL and token, the public URL. They are needed before
+the database is open, and two of them are credentials.
+
+**How the server behaves** lives in the database and is edited by an
+administrator in the web UI or through `PUT /api/admin/settings`, taking effect
+on the next sweep rather than at the next restart. The variables below marked
+**seed** are the value each of those starts from: written once, on first start,
+and never overwritten afterwards — a redeploy cannot put a chart's default back
+over an administrator's edit.
+
| Variable | Default | Description |
|---|---|---|
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
| `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` |
-| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | How long a resolved alert or incident stays in the default list before being auto-archived |
-| `TERDUT_STALE_AFTER` | `6h` | How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
+| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | **seed.** How long a resolved alert or incident stays in the default list before being auto-archived |
+| `TERDUT_STALE_AFTER` | `6h` | **seed.** How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
| `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | The **default** matchers a team starts with — switches are per team now, and this seeds teams that have no configuration of their own. `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` |
| `TERDUT_DEADMAN_TIMEOUT` | `15m` | How long a heartbeat may go unheard before its switch is declared dead — **must be shorter than the `repeat_interval` of the route carrying it**. `0` disables dead man's switch handling |
| `TERDUT_DEADMAN_SEVERITY` | `critical` | Severity a dead man's switch incident opens at |
@@ -168,7 +181,7 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
-| `TERDUT_NOTIFY_REPEAT` | `15m` | How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
+| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -522,11 +535,20 @@ on anybody's.
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
+| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
| `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once |
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key |
+### Administration
+
+| Method | Path | Who | Description |
+|---|---|---|---|
+| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
+| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
+| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds |
+
### Alert ingestion
Alerts arrive on a team's integration key. The key is both the credential and the
@@ -548,6 +570,7 @@ and was removed in v0.13.0 once senders had moved onto keys.
|---|---|---|---|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
+| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
diff --git a/cmd/terdut/main.go b/cmd/terdut/main.go
index 947475b..064fda8 100644
--- a/cmd/terdut/main.go
+++ b/cmd/terdut/main.go
@@ -45,7 +45,13 @@ func main() {
log.Fatalf("seed dead man's switch defaults: %v", err)
}
- router := api.NewRouter(database, notify)
+ // The behaviour knobs move into the database on first start, after which an
+ // administrator owns them and a redeploy leaves them alone.
+ if err := api.SeedSettings(context.Background(), database, cfg); err != nil {
+ log.Fatalf("seed settings: %v", err)
+ }
+
+ router := api.NewRouter(database, notify, cfg)
srv := &http.Server{
Addr: cfg.Addr,
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 038218f..0943e06 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -52,7 +52,7 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
}
database := newTestDB(t)
- srv := httptest.NewServer(api.NewRouter(database, cfg))
+ srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
diff --git a/internal/api/archiver.go b/internal/api/archiver.go
index 705b4b9..606ec2c 100644
--- a/internal/api/archiver.go
+++ b/internal/api/archiver.go
@@ -19,6 +19,10 @@ const (
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
// immediate pass so a restart reconciles state right away.
+// archiveAfter and staleAfter are the values the server started with. They are
+// 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.
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
@@ -45,6 +49,10 @@ func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter tim
// staleness rules would otherwise resolve it as 'expiry' long before that.
// Exported so tests can drive a pass without waiting on the ticker.
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
+ settings := NewSettings(db)
+ staleAfter = settings.Duration(ctx, SettingStaleAfter, staleAfter)
+ archiveAfter = settings.Duration(ctx, SettingArchiveAfter, archiveAfter)
+
heartbeats := sweepDeadman(ctx, db, notify)
expireStale(ctx, db, staleAfter, heartbeats)
resolveSettledIncidents(ctx, db)
diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go
index 5989bf4..27b9c58 100644
--- a/internal/api/auth_test.go
+++ b/internal/api/auth_test.go
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
func TestBootstrap_WithPassword(t *testing.T) {
database := newTestDB(t)
- srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}))
+ srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
t.Cleanup(srv.Close)
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index 0e983b9..64a7b59 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -144,8 +144,12 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
var u models.User
var createdUnix int64
+ // disabled_at IS NULL is part of the lookup rather than a check afterwards:
+ // a disabled account is one that cannot authenticate, by either credential,
+ // and the way to be sure of that is for there to be no path where the row
+ // is loaded and the flag is then forgotten.
if err := db.QueryRowContext(r.Context(),
- "SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID,
+ "SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
diff --git a/internal/api/notifier.go b/internal/api/notifier.go
index c341183..c955263 100644
--- a/internal/api/notifier.go
+++ b/internal/api/notifier.go
@@ -134,7 +134,11 @@ func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// queued, so an ntfy outage produces a retry backlog rather than a reminder
// backlog that all lands at once when it comes back.
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
- if cfg.RepeatEvery <= 0 {
+ // cfg.RepeatEvery is what the server started with; the settings table is
+ // what it runs on. Read per tick, so an administrator lengthening the
+ // interval at 02:00 is obeyed at 02:00 and not at the next restart.
+ repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
+ if repeat <= 0 {
return
}
now := time.Now()
@@ -156,7 +160,7 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
AND i.archived_at IS NULL
AND i.status = 'triggered'
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)`,
- now.Add(-cfg.RepeatEvery).Unix(), now.Unix())
+ now.Add(-repeat).Unix(), now.Unix())
if err != nil {
log.Printf("notifier: find reminders: %v", err)
return
diff --git a/internal/api/router.go b/internal/api/router.go
index 48e072b..3dd8fb3 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"net/http"
+ "git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -13,7 +14,7 @@ import (
// 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 {
+func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -70,6 +71,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
r.Post("/api/users", handleCreateUser(db))
r.Delete("/api/users/{id}", handleDeleteUser(db))
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
+ r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
+
+ // What exists on this server, and how it behaves. /api/teams
+ // answers "what am I in"; this one answers "what is there".
+ r.Get("/api/admin/teams", handleAdminListTeams(db))
+ r.Get("/api/admin/settings", handleGetSettings(db, cfg))
+ r.Put("/api/admin/settings", handleSetSettings(db))
})
// Alerts are read-only: they are Alertmanager's record, not a work
@@ -95,6 +103,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
// Teams. A user sees the teams they belong to; an owner configures one.
r.Get("/api/teams", handleListTeams(db))
r.Post("/api/teams", handleCreateTeam(db))
+ r.Put("/api/teams/{teamID}", handleRenameTeam(db))
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
diff --git a/internal/api/settings.go b/internal/api/settings.go
new file mode 100644
index 0000000..fee8b36
--- /dev/null
+++ b/internal/api/settings.go
@@ -0,0 +1,346 @@
+package api
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "git.ryuvia.com/niklas/terdut-server/internal/config"
+ "github.com/go-chi/chi/v5"
+)
+
+// The settings an administrator can change at runtime. Each is behaviour rather
+// than infrastructure: what the server does, not where it is plugged in.
+//
+// The values are seconds, stored as text. A duration string would be friendlier
+// to read in psql and worse everywhere else — it can be stored unparseable, and
+// then the question is what a background loop should do at 02:00 with a
+// tuning knob it cannot understand.
+const (
+ SettingNotifyRepeat = "notify_repeat_seconds"
+ SettingStaleAfter = "stale_after_seconds"
+ SettingArchiveAfter = "archive_after_seconds"
+)
+
+// settingBounds keeps an edit from producing a server that cannot work. The
+// ceilings are loose — they exist to catch a slipped decimal point, not to have
+// an opinion about anybody's rota.
+var settingBounds = map[string]struct {
+ min, max time.Duration
+ label string
+}{
+ SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
+ SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
+ SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
+}
+
+// Settings reads the runtime configuration. It holds no cache: the readers are
+// two background loops that tick every 30 seconds and 15 minutes, and handlers
+// that run once per request, so a query each time costs nothing measurable and
+// means an administrator's change takes effect on the next tick rather than at
+// the next restart.
+type Settings struct{ db *sql.DB }
+
+// NewSettings returns a reader over db.
+func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
+
+// Duration reads one setting, falling back to def when the row is missing or
+// unreadable. A tuning knob is never worth failing a sweep over: the fallback
+// is the value the server started with.
+func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
+ var raw string
+ err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
+ if err != nil {
+ return def
+ }
+ secs, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ return def
+ }
+ return time.Duration(secs) * time.Second
+}
+
+// SeedSettings writes each key from the server's environment configuration,
+// once. Never overwrites: after the first start the database owns these, and a
+// redeploy must not put a chart's default back over an administrator's edit —
+// the same rule as the per-team dead man's switches.
+func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
+ seeds := map[string]time.Duration{
+ SettingNotifyRepeat: cfg.NotifyRepeat,
+ SettingStaleAfter: cfg.StaleAfter,
+ SettingArchiveAfter: cfg.ArchiveAfter,
+ }
+ for key, d := range seeds {
+ if _, err := db.ExecContext(ctx, `
+ INSERT INTO settings (key, value) VALUES ($1, $2)
+ ON CONFLICT (key) DO NOTHING`,
+ key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// settingsResponse is what the admin page renders. The environment half is
+// included and marked read-only, so somebody looking for the ntfy URL finds out
+// where it lives rather than concluding the server does not have one.
+type settingsResponse struct {
+ Editable map[string]settingValue `json:"editable"`
+ FromEnv map[string]string `json:"from_env"`
+}
+
+type settingValue struct {
+ Seconds int64 `json:"seconds"`
+ Description string `json:"description"`
+ MinSeconds int64 `json:"min_seconds"`
+ MaxSeconds int64 `json:"max_seconds"`
+}
+
+func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
+ settings := NewSettings(db)
+ return func(w http.ResponseWriter, r *http.Request) {
+ out := settingsResponse{
+ Editable: map[string]settingValue{},
+ FromEnv: map[string]string{
+ // Never the ntfy token or the DSN: both are credentials, and an
+ // admin page that renders them turns a browser tab into a place
+ // they leak from.
+ "ntfy_url": cfg.NtfyURL,
+ "ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
+ "ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
+ "public_url": cfg.PublicURL,
+ "listen_address": cfg.Addr,
+ },
+ }
+ for key, b := range settingBounds {
+ def := map[string]time.Duration{
+ SettingNotifyRepeat: cfg.NotifyRepeat,
+ SettingStaleAfter: cfg.StaleAfter,
+ SettingArchiveAfter: cfg.ArchiveAfter,
+ }[key]
+ out.Editable[key] = settingValue{
+ Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
+ Description: b.label,
+ MinSeconds: int64(b.min.Seconds()),
+ MaxSeconds: int64(b.max.Seconds()),
+ }
+ }
+ respond(w, http.StatusOK, out)
+ }
+}
+
+// handleSetSettings changes one or more settings. Unknown keys are refused
+// rather than stored: a typo that writes notify_repeat_second would otherwise
+// sit in the table looking like configuration and doing nothing.
+func handleSetSettings(db *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req map[string]int64
+ if err := decodeJSON(r, &req); err != nil {
+ respond(w, http.StatusBadRequest, errResp("invalid request body"))
+ return
+ }
+ if len(req) == 0 {
+ respond(w, http.StatusBadRequest, errResp("no settings given"))
+ return
+ }
+
+ for key, secs := range req {
+ b, known := settingBounds[key]
+ if !known {
+ respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
+ return
+ }
+ d := time.Duration(secs) * time.Second
+ if d < b.min || d > b.max {
+ respond(w, http.StatusBadRequest, errResp(
+ key+" must be between "+b.min.String()+" and "+b.max.String()))
+ return
+ }
+ }
+
+ tx, err := db.BeginTx(r.Context(), nil)
+ if err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ for key, secs := range req {
+ if _, err := tx.ExecContext(r.Context(), `
+ INSERT INTO settings (key, value, updated_at)
+ VALUES ($1, $2, `+nowEpoch+`)
+ ON CONFLICT (key) DO UPDATE SET
+ value = excluded.value, updated_at = excluded.updated_at`,
+ key, strconv.FormatInt(secs, 10)); err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// handleAdminListTeams lists every team on the server, with its size. The
+// ordinary /api/teams answers "what am I in"; this one answers "what exists",
+// which only an administrator may ask.
+func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ rows, err := db.QueryContext(r.Context(), `
+ SELECT t.id, t.name, t.created_at,
+ (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
+ (SELECT COUNT(*) FROM incidents i
+ WHERE i.team_id = t.id AND i.resolved_at IS NULL)
+ FROM teams t
+ ORDER BY t.name`)
+ if err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ defer rows.Close()
+
+ type adminTeam struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ CreatedAt time.Time `json:"created_at"`
+ Members int64 `json:"members"`
+ OpenIncidents int64 `json:"open_incidents"`
+ }
+ teams := []adminTeam{}
+ for rows.Next() {
+ var t adminTeam
+ var created int64
+ if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ t.CreatedAt = time.Unix(created, 0).UTC()
+ teams = append(teams, t)
+ }
+ if err := rows.Err(); err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ respond(w, http.StatusOK, teams)
+ }
+}
+
+// handleRenameTeam renames a team. An owner's job, and an administrator's when
+// a team has nobody left to do it.
+func handleRenameTeam(db *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ teamID, ok := teamParam(w, r)
+ if !ok {
+ return
+ }
+ if !requireTeamOwner(w, r, teamID) {
+ return
+ }
+
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := decodeJSON(r, &req); err != nil || req.Name == "" {
+ respond(w, http.StatusBadRequest, errResp("name is required"))
+ return
+ }
+
+ res, err := db.ExecContext(r.Context(),
+ "UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
+ if err != nil {
+ if isUniqueViolation(err) {
+ respond(w, http.StatusConflict, errResp("a team with that name already exists"))
+ return
+ }
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ respond(w, http.StatusNotFound, errResp("not found"))
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// handleSetUserDisabled takes an account out of use, or puts it back.
+//
+// Not a delete: the person's acknowledgements, assignments and timeline entries
+// stay attached to them. Deleting a user nulls those columns, which rewrites
+// what happened during an incident months after the fact.
+func handleSetUserDisabled(db *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ respond(w, http.StatusBadRequest, errResp("invalid user id"))
+ return
+ }
+ var req struct {
+ Disabled *bool `json:"disabled"`
+ }
+ if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
+ respond(w, http.StatusBadRequest, errResp("disabled is required"))
+ return
+ }
+
+ if *req.Disabled {
+ caller, _ := userFromContext(r.Context())
+ if caller.ID == id {
+ respond(w, http.StatusConflict, errResp("cannot disable your own account"))
+ return
+ }
+ last, err := isLastAdmin(r.Context(), db, id)
+ if err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ if last {
+ respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
+ return
+ }
+ }
+
+ var res sql.Result
+ if *req.Disabled {
+ res, err = db.ExecContext(r.Context(),
+ "UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
+ } else {
+ res, err = db.ExecContext(r.Context(),
+ "UPDATE users SET disabled_at = NULL WHERE id = $1", id)
+ }
+ if err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ // Either no such user, or already in the state asked for. The
+ // second is not a failure, so check which before answering.
+ var exists int
+ if err := db.QueryRowContext(r.Context(),
+ "SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
+ respond(w, http.StatusNotFound, errResp("user not found"))
+ return
+ }
+ }
+
+ // Signing back in is the only way to use a re-enabled account, and a
+ // disabled one must not keep a live session.
+ if *req.Disabled {
+ db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
+ }
+
+ user, err := fetchUser(r.Context(), db, id)
+ if err != nil {
+ respond(w, http.StatusInternalServerError, errResp("internal error"))
+ return
+ }
+ respond(w, http.StatusOK, user)
+ }
+}
diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go
new file mode 100644
index 0000000..e8b6fd6
--- /dev/null
+++ b/internal/api/settings_test.go
@@ -0,0 +1,275 @@
+package api_test
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "git.ryuvia.com/niklas/terdut-server/internal/api"
+)
+
+// The settings an administrator can change, and the ones they cannot.
+func TestSettings_EditableAndReadOnly(t *testing.T) {
+ s := newTS(t)
+
+ var got struct {
+ Editable map[string]struct {
+ Seconds int64 `json:"seconds"`
+ Description string `json:"description"`
+ MinSeconds int64 `json:"min_seconds"`
+ MaxSeconds int64 `json:"max_seconds"`
+ } `json:"editable"`
+ FromEnv map[string]string `json:"from_env"`
+ }
+ decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
+
+ // Seeded from the environment the server started with, not from zero.
+ if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
+ t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
+ }
+ if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
+ t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
+ }
+ if got.Editable["archive_after_seconds"].Description == "" {
+ t.Error("a setting without a description is a number nobody can act on")
+ }
+
+ // The environment half is visible so somebody can see where it lives, but
+ // never the credentials themselves.
+ if _, ok := got.FromEnv["public_url"]; !ok {
+ t.Error("public_url should be reported as environment-configured")
+ }
+ for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
+ if v, ok := got.FromEnv[leak]; ok {
+ t.Errorf("%s must not be in the settings response (got %q)", leak, v)
+ }
+ }
+}
+
+// Changing a setting takes effect on the next tick, without a restart. This is
+// the whole point of moving them out of the environment.
+func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
+ s := newTS(t)
+
+ // An alert whose last webhook was two hours ago. Under the seeded
+ // stale_after of six hours the sweeper leaves it alone.
+ postWebhook(t, s, []map[string]any{
+ amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
+ })
+ s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
+ time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
+
+ sweep(t, s, noArchive)
+ if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
+ t.Fatalf("before the change the alert should still be firing, got %q", status)
+ }
+
+ // Shorten it to an hour. Nothing restarts.
+ resp := s.req(t, http.MethodPut, "/api/admin/settings",
+ map[string]int64{"stale_after_seconds": 3600})
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("change setting: %d", resp.StatusCode)
+ }
+
+ sweep(t, s, noArchive)
+ status, source, _ := s.alertRow(t, "fp-settings")
+ if status != "resolved" {
+ t.Errorf("after the change the alert should have expired, got %q", status)
+ }
+ if source == nil || *source != "expiry" {
+ t.Errorf("expected resolution_source expiry, got %v", source)
+ }
+}
+
+// A typo must not look like configuration, and a slipped decimal point must not
+// produce a server that sweeps every second.
+func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
+ s := newTS(t)
+
+ for _, c := range []struct {
+ name string
+ body map[string]int64
+ }{
+ {"unknown key", map[string]int64{"notify_repeat_second": 60}},
+ {"below the floor", map[string]int64{"stale_after_seconds": 30}},
+ {"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
+ {"nothing at all", map[string]int64{}},
+ } {
+ resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
+ }
+ }
+}
+
+// Settings are the server's behaviour, so only an administrator may change
+// them — or see where the rest of the configuration comes from.
+func TestSettings_AreAdminOnly(t *testing.T) {
+ s := newTS(t)
+ _, call := member(t, s, "member")
+
+ for _, c := range []struct {
+ method string
+ body any
+ }{
+ {http.MethodGet, nil},
+ {http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
+ } {
+ resp := call(c.method, "/api/admin/settings", c.body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
+ }
+ }
+
+ resp := call(http.MethodGet, "/api/admin/teams", nil)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
+ }
+}
+
+// An administrator sees every team, including ones they are not in — which is
+// exactly what /api/teams must not show them.
+func TestSettings_AdminSeesEveryTeam(t *testing.T) {
+ s := newTS(t)
+ newTeam(t, s, "red")
+ newTeam(t, s, "blue")
+
+ all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
+ if len(all) != 3 { // Default, red, blue
+ t.Fatalf("admin should see all 3 teams, saw %d", len(all))
+ }
+ for _, team := range all {
+ if _, ok := team["members"]; !ok {
+ t.Error("the admin listing should say how big each team is")
+ }
+ }
+
+ // The admin created them, so they own them — but they are not a member of
+ // a team somebody else makes, and /api/teams still answers "what am I in".
+ mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
+ if len(mine) != 3 {
+ t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
+ }
+}
+
+// Disabling is not deleting: the account stops working and the history stays.
+func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
+ s := newTS(t)
+ memberID, call := member(t, s, "leaver")
+
+ // They acknowledge an incident, so there is history to preserve.
+ postWebhook(t, s, []map[string]any{
+ amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
+ })
+ resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("acknowledge: %d", resp.StatusCode)
+ }
+
+ resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
+ map[string]bool{"disabled": true})
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("disable: %d", resp.StatusCode)
+ }
+
+ // Their API key stops working.
+ resp = call(http.MethodGet, "/api/incidents", nil)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
+ }
+
+ // The acknowledgement still names them.
+ var incident map[string]any
+ decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
+ if incident["acknowledged_by"] != "leaver" {
+ t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
+ }
+ if incident["status"] != "acknowledged" {
+ t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
+ }
+
+ // And re-enabling gives the account back.
+ resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
+ map[string]bool{"disabled": false})
+ resp.Body.Close()
+ resp = call(http.MethodGet, "/api/incidents", nil)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
+ }
+}
+
+// The same two guards as deleting and demoting: an install must keep somebody
+// who can administer it.
+func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
+ s := newTS(t)
+
+ resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusConflict {
+ t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
+ }
+}
+
+// Renaming a team is an owner's job, and the name stays unique.
+func TestSettings_TeamRename(t *testing.T) {
+ s := newTS(t)
+ team := newTeam(t, s, "red")
+
+ resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("rename: %d", resp.StatusCode)
+ }
+
+ teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
+ found := false
+ for _, x := range teams {
+ if x["name"] == "Platform" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("the renamed team should be listed under its new name")
+ }
+
+ // Taking a name that exists is a conflict, not a silent second team with
+ // the same label.
+ resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusConflict {
+ t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
+ }
+}
+
+// The seed runs once. A redeploy must not put the chart's default back over an
+// administrator's edit — the rule the dead man's switches already follow.
+func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
+ s := newTS(t)
+
+ resp := s.req(t, http.MethodPut, "/api/admin/settings",
+ map[string]int64{"notify_repeat_seconds": 60})
+ resp.Body.Close()
+
+ // A second start, with the environment still saying 15 minutes.
+ if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
+ t.Fatalf("re-seed: %v", err)
+ }
+
+ var got struct {
+ Editable map[string]struct {
+ Seconds int64 `json:"seconds"`
+ } `json:"editable"`
+ }
+ decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
+ if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
+ t.Errorf("the edit should survive a restart, got %d", v)
+ }
+}
diff --git a/internal/api/testdb_test.go b/internal/api/testdb_test.go
index 647add3..33d6294 100644
--- a/internal/api/testdb_test.go
+++ b/internal/api/testdb_test.go
@@ -7,7 +7,9 @@ import (
"os"
"strings"
"testing"
+ "time"
+ "git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
@@ -30,6 +32,19 @@ import (
// tests nothing is worse than one that does not run.
const testDSNEnv = "TERDUT_TEST_DSN"
+// testConfig is the environment half of the server's configuration, which the
+// admin settings page renders read-only and SeedSettings seeds the editable
+// half from. The durations match the defaults config.Load would produce, so a
+// test that never touches the settings table behaves as a fresh install does.
+func testConfig() config.Config {
+ return config.Config{
+ Addr: ":8080",
+ ArchiveAfter: 7 * 24 * time.Hour,
+ StaleAfter: 6 * time.Hour,
+ NotifyRepeat: 15 * time.Minute,
+ }
+}
+
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
// a path segment. Every test that does not say otherwise works inside it.
const defaultTeam = "1"
diff --git a/internal/api/users.go b/internal/api/users.go
index ab28337..10caffb 100644
--- a/internal/api/users.go
+++ b/internal/api/users.go
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
- "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users ORDER BY id")
+ "SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -107,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var u models.User
var ts int64
- if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin); err != nil {
+ var disabled *int64
+ if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
u.CreatedAt = time.Unix(ts, 0).UTC()
+ u.DisabledAt = unixPtr(disabled)
users = append(users, u)
}
respond(w, http.StatusOK, users)
@@ -325,13 +327,15 @@ func randomToken() (raw, hash string, err error) {
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
+ var disabled *int64
err := db.QueryRowContext(ctx,
- "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id).
- Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin)
+ "SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
+ Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
if err != nil {
return u, err
}
u.CreatedAt = time.Unix(ts, 0).UTC()
+ u.DisabledAt = unixPtr(disabled)
return u, nil
}
diff --git a/internal/db/migrations/005_settings.sql b/internal/db/migrations/005_settings.sql
new file mode 100644
index 0000000..c9e060c
--- /dev/null
+++ b/internal/db/migrations/005_settings.sql
@@ -0,0 +1,35 @@
+-- Settings that an administrator can change without a redeploy, and the flag
+-- that takes an account out of use without deleting it.
+--
+-- Three of the server's tunables were environment variables, which meant
+-- changing how long an incident waits before it is paged again required editing
+-- a chart, merging it, and waiting for a reconcile. They are behaviour, not
+-- infrastructure, and the difference is who needs to change them and how often.
+--
+-- What stays in the environment: the ntfy URL and token, the database DSN, the
+-- listen address and the public URL. Those are where the server is plugged in
+-- rather than how it behaves, they are needed before the database is open, and
+-- two of them are credentials.
+--
+-- Key/value rather than a column per setting. A settings table with one row and
+-- a column per knob needs a migration for every new knob, and #6 and #7 will
+-- both add some. The cost is that values are text and the accessor has to say
+-- what type it wanted; settings.go does that in one place.
+--
+-- No rows are seeded here: a migration cannot read the environment. The server
+-- inserts each key from its own configuration at startup, once, so an install
+-- that upgrades keeps exactly the behaviour it had. See SeedSettings.
+CREATE TABLE settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
+);
+
+-- Disabling an account rather than deleting it: the person has left, or the
+-- credential is suspect, and their incidents, acknowledgements and timeline
+-- entries must stay exactly where they are. Deleting a user nulls their
+-- acknowledged_by and assigned_to, which quietly rewrites history.
+--
+-- A disabled user cannot sign in and their API keys stop working, but they are
+-- still a name the timeline can show and still a member of their teams.
+ALTER TABLE users ADD COLUMN disabled_at BIGINT;
diff --git a/internal/models/user.go b/internal/models/user.go
index 7985ba8..c75ef95 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -13,6 +13,11 @@ type User struct {
// fallback topic instead.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
+ // DisabledAt is when the account was taken out of use, or nil. A disabled
+ // user cannot authenticate by either credential, and keeps their name on
+ // every acknowledgement and timeline entry they made.
+ DisabledAt *time.Time `json:"disabled_at,omitempty"`
+
// IsAdmin is the system administrator flag: managing users and API keys.
// Not omitempty — a client has to be able to tell "false" from "this server
// is too old to have the field", and the web UI decides what to show from
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 17567ca..c9bdd6e 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -630,3 +630,30 @@ kbd {
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.only-desktop { display: block; }
}
+
+/* --- admin ---------------------------------------------------------------
+ The admin page is three tables of things you act on, so it needs table
+ styling the rest of the app never did: the queue is a list of links and the
+ account page is a form. */
+.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.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);
+}
+.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
+.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; }
+/* A disabled account stays readable — it is still the name on old
+ acknowledgements — but should not look like a working one. */
+.disabled-row td { opacity: 0.55; }
+.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
+
+.inline-form { display: flex; gap: 8px; margin-top: 12px; }
+.inline-form input { flex: 1; min-width: 0; }
+
+.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
+.admin-settings .setting-unit { max-width: 8em; }
+.admin-settings button[type="submit"] { margin-top: 12px; }
+.small { font-size: 13px; }
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index ef269b4..bbc1d3e 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -59,6 +59,13 @@
Alerts
+
+
+
+ Admin
+ Account
@@ -80,6 +87,7 @@
+
diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js
new file mode 100644
index 0000000..beeb97a
--- /dev/null
+++ b/internal/web/static/js/admin.js
@@ -0,0 +1,308 @@
+// Administration: the teams on this server, the people who can sign in, and
+// the settings that change how the server behaves.
+//
+// Only rendered for a system administrator. The server enforces that on every
+// endpoint regardless — hiding a section is a courtesy to the reader, not a
+// permission — so this view simply says so rather than pretending to be a
+// gate.
+
+import * as api from './api.js';
+import { h, clear, spinner, confirm } from './ui.js';
+import { state, myID } from './state.js';
+
+const view = () => document.getElementById('view-admin');
+
+let data = null; // { teams, users, settings }
+let error = null;
+let busy = false;
+
+export function show() {
+ if (!data) clear(view(), spinner());
+ refresh();
+}
+
+export async function refresh() {
+ if (!state.me?.user?.is_admin) {
+ data = null;
+ render();
+ return;
+ }
+ try {
+ const [teams, users, settings] = await Promise.all([
+ api.adminTeams(),
+ api.users(),
+ api.adminSettings(),
+ ]);
+ data = { teams, users, settings };
+ error = null;
+ } catch (err) {
+ error = err.message;
+ }
+ render();
+}
+
+function render() {
+ if (!state.me?.user?.is_admin) {
+ clear(view(), h('div', { class: 'card' },
+ h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
+ return;
+ }
+ if (!data) {
+ clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
+ return;
+ }
+ clear(view(),
+ error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
+ teamsCard(),
+ usersCard(),
+ settingsCard(),
+ );
+}
+
+// --- teams -----------------------------------------------------------------
+
+function teamsCard() {
+ const rows = data.teams.map((t) =>
+ h('tr', {},
+ h('td', {}, h('strong', { text: t.name })),
+ h('td', { class: 'num', text: String(t.members) }),
+ h('td', { class: 'num', text: String(t.open_incidents) }),
+ h('td', {},
+ h('button', {
+ class: 'btn-sm',
+ type: 'button',
+ text: 'Rename',
+ onclick: () => renameTeam(t),
+ }),
+ // A team with open incidents cannot be deleted, and saying so before
+ // the click is kinder than a 409 afterwards.
+ h('button', {
+ class: 'btn-sm danger',
+ type: 'button',
+ text: 'Delete',
+ disabled: t.open_incidents > 0,
+ title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
+ onclick: () => deleteTeam(t),
+ }),
+ ),
+ ));
+
+ return h('div', { class: 'card' },
+ h('h2', { text: 'Teams' }),
+ h('table', { class: 'admin-table' },
+ h('thead', {}, h('tr', {},
+ h('th', { text: 'Name' }),
+ h('th', { class: 'num', text: 'Members' }),
+ h('th', { class: 'num', text: 'Open' }),
+ h('th', { text: '' }))),
+ h('tbody', {}, rows)),
+ newTeamForm(),
+ );
+}
+
+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' }));
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (busy) return;
+ busy = true;
+ try {
+ await api.createTeam(name.value.trim());
+ name.value = '';
+ await refresh();
+ } catch (err) {
+ error = err.message;
+ render();
+ } finally {
+ busy = false;
+ }
+ });
+ return form;
+}
+
+async function renameTeam(team) {
+ const next = window.prompt(`Rename ${team.name} to:`, team.name);
+ if (!next || next === team.name) return;
+ try {
+ await api.renameTeam(team.id, next);
+ } catch (err) {
+ error = err.message;
+ }
+ refresh();
+}
+
+async function deleteTeam(team) {
+ if (!(await confirm({
+ title: `Delete ${team.name}?`,
+ text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
+ confirmLabel: 'Delete',
+ danger: true,
+ }))) return;
+ try {
+ await api.deleteTeam(team.id);
+ } catch (err) {
+ error = err.message;
+ }
+ refresh();
+}
+
+// --- users -----------------------------------------------------------------
+
+function usersCard() {
+ const rows = data.users.map((u) => {
+ const self = u.id === myID();
+ return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
+ h('td', {},
+ h('strong', { text: u.username }),
+ u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
+ self && h('span', { class: 'you', text: 'you' })),
+ h('td', { class: 'muted', text: u.email }),
+ h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
+ h('td', {},
+ // Neither action is offered for your own account: the server refuses
+ // both, and an enabled-looking button that always fails is worse than
+ // no button.
+ !self && h('button', {
+ class: 'btn-sm',
+ type: 'button',
+ text: u.is_admin ? 'Revoke admin' : 'Make admin',
+ onclick: () => setAdmin(u, !u.is_admin),
+ }),
+ !self && h('button', {
+ class: 'btn-sm danger',
+ type: 'button',
+ text: u.disabled_at ? 'Enable' : 'Disable',
+ onclick: () => setDisabled(u, !u.disabled_at),
+ }),
+ ),
+ );
+ });
+
+ return h('div', { class: 'card' },
+ h('h2', { text: 'Users' }),
+ h('p', { class: 'muted small' },
+ 'Disabling an account stops it signing in and stops its API keys, and keeps ',
+ 'its acknowledgements and timeline entries. Deleting a user erases those.'),
+ h('table', { class: 'admin-table' },
+ h('thead', {}, h('tr', {},
+ h('th', { text: 'User' }),
+ h('th', { text: 'Email' }),
+ h('th', { text: '' }),
+ h('th', { text: '' }))),
+ h('tbody', {}, rows)),
+ );
+}
+
+async function setAdmin(user, next) {
+ if (next && !(await confirm({
+ title: `Make ${user.username} an administrator?`,
+ text: 'They will be able to create and delete users, and grant this to others.',
+ confirmLabel: 'Make admin',
+ }))) return;
+ try {
+ await api.setUserAdmin(user.id, next);
+ } catch (err) {
+ error = err.message;
+ }
+ refresh();
+}
+
+async function setDisabled(user, next) {
+ if (next && !(await confirm({
+ title: `Disable ${user.username}?`,
+ text: 'They cannot sign in and their API keys stop working. Their history stays.',
+ confirmLabel: 'Disable',
+ danger: true,
+ }))) return;
+ try {
+ await api.setUserDisabled(user.id, next);
+ } catch (err) {
+ error = err.message;
+ }
+ refresh();
+}
+
+// --- settings --------------------------------------------------------------
+
+// Seconds are what the API speaks; people think in minutes and hours. The two
+// are converted here rather than in the server, which should keep exactly one
+// unit.
+const UNITS = [
+ { label: 'minutes', seconds: 60 },
+ { label: 'hours', seconds: 3600 },
+ { label: 'days', seconds: 86400 },
+];
+
+function bestUnit(seconds) {
+ for (const u of [...UNITS].reverse()) {
+ if (seconds > 0 && seconds % u.seconds === 0) return u;
+ }
+ return UNITS[0];
+}
+
+function settingsCard() {
+ const editable = data.settings.editable || {};
+ const inputs = new Map();
+
+ const rows = Object.entries(editable).map(([key, s]) => {
+ const unit = bestUnit(s.seconds);
+ const value = h('input', {
+ type: 'number',
+ min: '0',
+ value: String(Math.round(s.seconds / unit.seconds)),
+ class: 'setting-value',
+ });
+ const select = h('select', { class: 'setting-unit' },
+ ...UNITS.map((u) => h('option', {
+ value: String(u.seconds),
+ text: u.label,
+ selected: u.seconds === unit.seconds,
+ })));
+ inputs.set(key, () => Number(value.value) * Number(select.value));
+
+ return h('tr', {},
+ h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })),
+ h('td', { class: 'muted small', text: s.description }),
+ h('td', {}, value, select),
+ );
+ });
+
+ const form = h('form', { class: 'admin-settings' },
+ h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
+ h('button', { class: 'btn', type: 'submit', text: 'Save settings' }));
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (busy) return;
+ busy = true;
+ const body = {};
+ for (const [key, read] of inputs) body[key] = read();
+ try {
+ await api.setAdminSettings(body);
+ await refresh();
+ } catch (err) {
+ error = err.message;
+ render();
+ } finally {
+ busy = false;
+ }
+ });
+
+ const env = Object.entries(data.settings.from_env || {}).map(([k, v]) =>
+ h('tr', {},
+ h('td', {}, h('code', { text: k })),
+ h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
+
+ return h('div', { class: 'card' },
+ h('h2', { text: 'Settings' }),
+ h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
+ form,
+ h('h3', { text: 'From the environment' }),
+ h('p', { class: 'muted small' },
+ 'Where the server is plugged in, rather than how it behaves. These are set ',
+ 'in the deployment and are read-only here. Credentials are never shown.'),
+ h('table', { class: 'admin-table' }, h('tbody', {}, env)),
+ );
+}
diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js
index 150e3f1..d9cb09f 100644
--- a/internal/web/static/js/api.js
+++ b/internal/web/static/js/api.js
@@ -88,6 +88,19 @@ export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }
// schedule
export const teams = () => call('GET', '/teams');
+export const createTeam = (name) => call('POST', '/teams', { body: { name } });
+export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
+export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
+
+// Administration. Every one of these is refused with 403 for anybody without
+// the flag, so the UI hides the section rather than guarding it.
+export const adminTeams = () => call('GET', '/admin/teams');
+export const adminSettings = () => call('GET', '/admin/settings');
+export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
+export const setUserAdmin = (id, isAdmin) =>
+ call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
+export const setUserDisabled = (id, disabled) =>
+ call('PUT', `/users/${id}/disabled`, { body: { disabled } });
export const schedule = (teamID, from, to) =>
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js
index 5415278..bc6121c 100644
--- a/internal/web/static/js/app.js
+++ b/internal/web/static/js/app.js
@@ -9,6 +9,7 @@ import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as account from './account.js';
+import * as admin from './admin.js';
const $ = (id) => document.getElementById(id);
@@ -17,6 +18,7 @@ const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
+ admin: { title: 'Admin', view: admin },
more: { title: 'Account', view: account },
};
@@ -24,7 +26,7 @@ function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
- if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
+ if (name === 'oncall' || name === 'alerts' || name === 'admin' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
@@ -142,6 +144,9 @@ async function boot() {
try {
state.me = await api.me();
await loadTeams();
+ // The Admin tab exists only for an administrator. Somebody who types /admin
+ // anyway gets the view's own "ask an administrator" card, not a blank page.
+ $('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (err) {
if (err.status === 401) showLogin();