Add an admin page, and move the behaviour settings into the database
Closes #5. Three of the server's tunables were environment variables, which meant changing how long an incident waits before being paged again required editing a chart, merging it and waiting for a reconcile. They are behaviour rather than infrastructure, and the difference is who needs to change them and how often. The split is by who owns the value. What stays in the environment is where the server is plugged in: the listen address, the DSN, the ntfy URL and token, the public URL. Those are needed before the database is open and two of them are credentials -- the settings endpoint reports that ntfy is configured and that a token is set, and never what either is. What moves is how it behaves: the notify repeat interval, the stale window and the archive window. The environment variable becomes the seed rather than the setting, written once on first start and never overwritten, so a redeploy cannot put a chart's default back over an administrator's edit -- the rule the per-team dead man's switches already follow. The loops read the current value per tick, so a change at 02:00 is obeyed at 02:00. Key/value rather than a column per knob: #6 and #7 will both add settings, and a table shaped one-column-per-setting needs a migration for each. The cost is that values are text and the accessor has to say what type it wanted, which settings.go does in one place. Unknown keys are refused rather than stored -- a typo that wrote notify_repeat_second would otherwise sit in the table looking like configuration and doing nothing -- and each value has bounds loose enough to catch a slipped decimal point without having an opinion about anybody's rota. Disabling an account is new, and is not deleting one. Deleting a user nulls acknowledged_by and assigned_to, which quietly rewrites who did what during an incident months after the fact. A disabled user cannot authenticate by either credential, loses their sessions immediately, and stays the name on every acknowledgement they made. The check is part of the lookup in serveAs rather than a test afterwards, so there is no path where the row is loaded and the flag is then forgotten. The page itself is a fourth tab, shown only to an administrator and only as a courtesy: every endpoint under it is refused with 403 regardless, so somebody who types /admin gets an explanation rather than a blank screen. It lists teams with their size and open-incident count, users with their flags, and the settings with their bounds -- plus the environment half, read-only, so somebody hunting for the ntfy URL learns where it lives instead of concluding the server has none. Delete is disabled rather than offered-and-refused for a team with open incidents, and neither admin action is offered on your own account, since the server refuses both. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
@@ -155,12 +155,25 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g
|
|||||||
|
|
||||||
## Configuration
|
## 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 |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
|
| `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_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_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` | How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** |
|
| `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_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_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 |
|
| `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_TOKEN` | — | Bearer token for an access-controlled ntfy |
|
||||||
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
| `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_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.
|
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 |
|
| `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 |
|
| `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}/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}/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 |
|
| `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 |
|
| `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 |
|
| `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
|
### Alert ingestion
|
||||||
|
|
||||||
Alerts arrive on a team's integration key. The key is both the credential and the
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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"}` |
|
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
||||||
|
|||||||
+7
-1
@@ -45,7 +45,13 @@ func main() {
|
|||||||
log.Fatalf("seed dead man's switch defaults: %v", err)
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
|||||||
}
|
}
|
||||||
|
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
srv := httptest.NewServer(api.NewRouter(database, cfg))
|
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const (
|
|||||||
|
|
||||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||||
// immediate pass so a restart reconciles state right away.
|
// 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) {
|
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
ticker := time.NewTicker(sweepInterval)
|
ticker := time.NewTicker(sweepInterval)
|
||||||
defer ticker.Stop()
|
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.
|
// staleness rules would otherwise resolve it as 'expiry' long before that.
|
||||||
// Exported so tests can drive a pass without waiting on the ticker.
|
// 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) {
|
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)
|
heartbeats := sweepDeadman(ctx, db, notify)
|
||||||
expireStale(ctx, db, staleAfter, heartbeats)
|
expireStale(ctx, db, staleAfter, heartbeats)
|
||||||
resolveSettledIncidents(ctx, db)
|
resolveSettledIncidents(ctx, db)
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
|
|||||||
|
|
||||||
func TestBootstrap_WithPassword(t *testing.T) {
|
func TestBootstrap_WithPassword(t *testing.T) {
|
||||||
database := newTestDB(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)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
||||||
|
|||||||
@@ -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) {
|
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
||||||
var u models.User
|
var u models.User
|
||||||
var createdUnix int64
|
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(),
|
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 {
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
// queued, so an ntfy outage produces a retry backlog rather than a reminder
|
||||||
// backlog that all lands at once when it comes back.
|
// backlog that all lands at once when it comes back.
|
||||||
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
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
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
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.archived_at IS NULL
|
||||||
AND i.status = 'triggered'
|
AND i.status = 'triggered'
|
||||||
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)`,
|
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 {
|
if err != nil {
|
||||||
log.Printf("notifier: find reminders: %v", err)
|
log.Printf("notifier: find reminders: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+10
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"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
|
// 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
|
// notify disables notifications. Dead man's switches are per team and read from
|
||||||
// the database, so nothing about them is wired in here.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -70,6 +71,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
|
|||||||
r.Post("/api/users", handleCreateUser(db))
|
r.Post("/api/users", handleCreateUser(db))
|
||||||
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||||
r.Put("/api/users/{id}/admin", handleSetAdmin(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
|
// 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.
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||||
r.Get("/api/teams", handleListTeams(db))
|
r.Get("/api/teams", handleListTeams(db))
|
||||||
r.Post("/api/teams", handleCreateTeam(db))
|
r.Post("/api/teams", handleCreateTeam(db))
|
||||||
|
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||||
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||||
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
||||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +32,19 @@ import (
|
|||||||
// tests nothing is worse than one that does not run.
|
// tests nothing is worse than one that does not run.
|
||||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
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
|
// 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.
|
// a path segment. Every test that does not say otherwise works inside it.
|
||||||
const defaultTeam = "1"
|
const defaultTeam = "1"
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := db.QueryContext(r.Context(),
|
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 {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -107,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
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"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
respond(w, http.StatusOK, users)
|
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) {
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
|
var disabled *int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id).
|
"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)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -13,6 +13,11 @@ type User struct {
|
|||||||
// fallback topic instead.
|
// fallback topic instead.
|
||||||
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
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.
|
// 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
|
// 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
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
|
|||||||
@@ -630,3 +630,30 @@ kbd {
|
|||||||
.toast, .app.detail-open ~ .toast { bottom: 24px; }
|
.toast, .app.detail-open ~ .toast { bottom: 24px; }
|
||||||
.only-desktop { display: block; }
|
.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; }
|
||||||
|
|||||||
@@ -59,6 +59,13 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
|
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
||||||
|
<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" href="/more" data-section="more">
|
<a class="nav-link" href="/more" data-section="more">
|
||||||
<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>
|
<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-label">Account</span>
|
||||||
@@ -80,6 +87,7 @@
|
|||||||
|
|
||||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,6 +88,19 @@ export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }
|
|||||||
|
|
||||||
// schedule
|
// schedule
|
||||||
export const teams = () => call('GET', '/teams');
|
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) =>
|
export const schedule = (teamID, from, to) =>
|
||||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as incident from './incident.js';
|
|||||||
import * as oncall from './oncall.js';
|
import * as oncall from './oncall.js';
|
||||||
import * as alerts from './alerts.js';
|
import * as alerts from './alerts.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
|
import * as admin from './admin.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ const SECTIONS = {
|
|||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
|
admin: { title: 'Admin', view: admin },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ function parseRoute(pathname) {
|
|||||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
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 };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +144,9 @@ async function boot() {
|
|||||||
try {
|
try {
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
await loadTeams();
|
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();
|
showApp();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) showLogin();
|
if (err.status === 401) showLogin();
|
||||||
|
|||||||
Reference in New Issue
Block a user