diff --git a/README.md b/README.md index a47945e..338a82b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Incident management server for teams using Prometheus Alertmanager. - Incident workflow: acknowledge, assign, snooze, note, resolve, with a full timeline - On-call schedule management, with new incidents auto-assigned to whoever is on call - Alert and incident statistics, including MTTA and MTTR +- Web UI for phones and desktops, served by the same binary - REST API with per-user API key authentication - Single binary, SQLite storage — trivial to self-host @@ -29,10 +30,11 @@ The server starts on `:8080` with a `terdut.db` file in the working directory. ```bash curl -X POST http://localhost:8080/api/bootstrap \ -H "Content-Type: application/json" \ - -d '{"username": "admin", "email": "admin@example.com"}' + -d '{"username": "admin", "email": "admin@example.com", "password": ""}' ``` -Save the `api_key.key` value from the response — it is shown **once only**. +Save the `api_key.key` value from the response — it is shown **once only**. The +`password` is optional and is what signs you in to the [web UI](#web-ui). Use it as a bearer token for all subsequent requests: @@ -41,6 +43,48 @@ export KEY= curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users ``` +### Web UI + +The server serves a web UI at `/`: the incident queue, each incident's alerts +and timeline with every action (acknowledge, assign, snooze, note, resolve, +archive), who is on call, the alert feed, and changing your own password. It is +built for a phone first. On a phone it has a bottom tab bar and a sticky action +bar, it follows the system's dark mode, and it can be added to the home screen. +From 900px wide it switches to a sidebar with the queue and the incident side by +side. Schedule editing, statistics and user management remain in +[terdut-tui](https://github.com/yeniklas/terdut-tui) for now. + +You sign in with a username and password. Users have no password until one is +set, and a user without one can only use API keys: + +```bash +# an admin sets someone's first password with their API key +curl -X PUT http://localhost:8080/api/users/2/password \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"password": ""}' +``` + +After that, users change it themselves under *Account*. Changing your own +password requires the current one. + +How a browser stays signed in: + +- A successful login sets an `HttpOnly`, `SameSite=Lax` session cookie. It lasts + 30 days and slides forward while it is used, so an on-call phone stays signed + in. +- The cookie is marked `Secure` when `TERDUT_PUBLIC_URL` starts with `https://`, + so set it to the HTTPS address. TLS terminates at the gateway and the server + itself only ever sees plain HTTP. +- Requests authenticated by the cookie are checked for cross-origin use (Go's + `http.CrossOriginProtection`). That is the CSRF guard. Bearer-key clients are + not affected. +- Setting a password signs that user out everywhere else. +- Ten failed logins for one username within 15 minutes lock that username for + the rest of the window. + +With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in +the web UI (`/incidents/{id}`). + ### Docker ```bash @@ -107,7 +151,7 @@ Set `backupSidecar.enabled=false` if you back the volume up some other way. | `TERDUT_NTFY_URL` | — | ntfy server to publish push notifications to. Empty disables notifications entirely | | `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, for the link and Acknowledge button inside a notification | +| `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 | Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default. @@ -375,21 +419,32 @@ of the last heartbeat, and the heartbeat's labels are on the incident's ### Authentication -All endpoints except `/api/bootstrap` and `/api/alertmanager/webhook` require: +All endpoints except `/api/bootstrap`, `/api/alertmanager/webhook`, +`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key: ``` Authorization: Bearer ``` +or the web UI's session cookie. A request that carries an `Authorization` header +is judged on that header alone. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures | +| `POST` | `/api/logout` | Ends the session and clears the cookie | +| `GET` | `/api/me` | The caller: `{user, has_password}` | + ### Users | Method | Path | Description | |---|---|---| -| `POST` | `/api/bootstrap` | Create first user + API key (only works on empty DB) | +| `POST` | `/api/bootstrap` | Create first user + API key `{"username","email","password"?}` (only works on empty DB) | | `GET` | `/api/users` | List users | | `POST` | `/api/users` | Create user `{"username","email"}` | | `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) | | `PUT` | `/api/users/{id}/notify` | Set push notification target `{"ntfy_topic"}` — empty string clears it | +| `PUT` | `/api/users/{id}/password` | 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` | Issue API key `{"name"}` — key shown once | | `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key | @@ -668,6 +723,10 @@ adds `-race`, which CI does not. The sweeper, the notifier goroutine and the dea sweep all touch the same single database connection, and a race between them would surface as a flaky incident in production rather than as a red build. +The web UI lives in `internal/web/static/` as plain HTML, CSS and ES modules, +embedded into the binary with `go:embed`. It has no build step and no npm, so +editing a file and restarting the server is the whole loop. + ## Releasing ``` diff --git a/go.mod b/go.mod index 068d1a0..40be8c7 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module git.ryuvia.com/niklas/terdut-server -go 1.25.9 +go 1.26.0 require ( github.com/go-chi/chi/v5 v5.2.5 + golang.org/x/crypto v0.57.0 modernc.org/sqlite v1.50.1 ) @@ -13,7 +14,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.48.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3cf4769..a094dac 100644 --- a/go.sum +++ b/go.sum @@ -14,13 +14,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/api/archiver.go b/internal/api/archiver.go index 3248d64..2374f0f 100644 --- a/internal/api/archiver.go +++ b/internal/api/archiver.go @@ -51,6 +51,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati archiveResolved(ctx, db, archiveAfter) archiveResolvedIncidents(ctx, db, archiveAfter) purgeAckTokens(ctx, db) + purgeSessions(ctx, db) } // expireStale resolves firing alerts that Alertmanager has stopped refreshing. diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..30f4a67 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,356 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "log" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +const ( + // sessionCookie carries a web UI session. It is HttpOnly, so page script + // never sees the token; the page learns who it is from GET /api/me. + sessionCookie = "terdut_session" + + // sessionTTL is how long a session lives without being used. It slides, so + // a phone that opens the UI now and then stays signed in indefinitely. + sessionTTL = 30 * 24 * time.Hour + + // sessionTouchEvery bounds how often a request may slide the expiry. + sessionTouchEvery = time.Hour + + minPasswordLen = 10 + // maxPasswordLen is bcrypt's limit; it rejects longer input outright. + maxPasswordLen = 72 + + loginWindow = 15 * time.Minute + loginMaxPerUser = 10 + loginMaxPerAddr = 30 + passwordHashCost = bcrypt.DefaultCost +) + +// dummyHash is compared against when the username is unknown or has no +// password, so a failed login takes as long whichever way it failed. +var dummyHash = sync.OnceValue(func() []byte { + h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost) + return h +}) + +// loginLimiter counts failed logins in a fixed window, per username and per +// client address. The username limit is what stops guessing one account; the +// address limit is looser because every user behind the same gateway or NAT +// shares it. +type loginLimiter struct { + mu sync.Mutex + failures map[string]*loginWindowCount +} + +type loginWindowCount struct { + start time.Time + n int +} + +func newLoginLimiter() *loginLimiter { + return &loginLimiter{failures: map[string]*loginWindowCount{}} +} + +func (l *loginLimiter) blocked(key string, max int) bool { + l.mu.Lock() + defer l.mu.Unlock() + c, ok := l.failures[key] + if !ok || time.Since(c.start) > loginWindow { + return false + } + return c.n >= max +} + +func (l *loginLimiter) fail(keys ...string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + for k, c := range l.failures { + if now.Sub(c.start) > loginWindow { + delete(l.failures, k) + } + } + for _, key := range keys { + c, ok := l.failures[key] + if !ok { + c = &loginWindowCount{start: now} + l.failures[key] = c + } + c.n++ + } +} + +func (l *loginLimiter) clear(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.failures, key) +} + +// clientAddr is the address a login is counted against. Behind the gateway +// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used +// when present. It can be forged, but only to dodge the address limit; the +// per-username limit does not depend on it. +func clientAddr(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first, _, _ := strings.Cut(xff, ",") + return strings.TrimSpace(first) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway, +// so the server usually sees plain HTTP; the public URL is what says whether +// browsers reach it over HTTPS. +func cookieSecure(publicURL string, r *http.Request) bool { + return strings.HasPrefix(publicURL, "https://") || + r.TLS != nil || + r.Header.Get("X-Forwarded-Proto") == "https" +} + +// validatePassword returns a message for the client, or "" when acceptable. +func validatePassword(pw string) string { + switch { + case len(pw) < minPasswordLen: + return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters" + case len(pw) > maxPasswordLen: + return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes" + } + return "" +} + +func hashPassword(pw string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost) + return string(h), err +} + +// handleLogin exchanges a username and password for a session cookie. +func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + username := strings.TrimSpace(req.Username) + userKey := "user:" + strings.ToLower(username) + addrKey := "addr:" + clientAddr(r) + + if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) { + w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) + respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later")) + return + } + + var userID int64 + var hash sql.NullString + err := db.QueryRowContext(r.Context(), + "SELECT id, password_hash FROM users WHERE username = ?", username, + ).Scan(&userID, &hash) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + stored := dummyHash() + if hash.Valid { + stored = []byte(hash.String) + } + match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil + if !match || !hash.Valid { + limiter.fail(userKey, addrKey) + respond(w, http.StatusUnauthorized, errResp("invalid username or password")) + return + } + limiter.clear(userKey) + + raw, tokenHash, err := randomToken() + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + now := time.Now() + if _, err := db.ExecContext(r.Context(), ` + INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent) + VALUES (?, ?, ?, ?, ?, ?)`, + tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: raw, + Path: "/", + MaxAge: int(sessionTTL.Seconds()), + HttpOnly: true, + Secure: cookieSecure(publicURL, r), + SameSite: http.SameSiteLaxMode, + }) + + user, err := fetchUser(r.Context(), db, userID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, meResponse{User: user, HasPassword: true}) + } +} + +// handleLogout ends the browser's session. It sits outside AuthMiddleware so +// that a browser holding an already-expired cookie can still clear it. +func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc { + crossOrigin := http.NewCrossOriginProtection() + return func(w http.ResponseWriter, r *http.Request) { + if err := crossOrigin.Check(r); err != nil { + respond(w, http.StatusForbidden, errResp("cross-origin request rejected")) + return + } + if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { + db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = ?", hashToken(c.Value)) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: cookieSecure(publicURL, r), + SameSite: http.SameSiteLaxMode, + }) + w.WriteHeader(http.StatusNoContent) + } +} + +type meResponse struct { + User any `json:"user"` + HasPassword bool `json:"has_password"` +} + +// handleMe says who the caller is. The web UI calls it on load to decide +// between the login form and the app, since it cannot read its own cookie. +func handleMe(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caller, _ := userFromContext(r.Context()) + user, err := fetchUser(r.Context(), db, caller.ID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + var hash sql.NullString + db.QueryRowContext(r.Context(), + "SELECT password_hash FROM users WHERE id = ?", caller.ID).Scan(&hash) + respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid}) + } +} + +// handleSetPassword sets a user's web UI password. +// +// Changing your own password takes the current one, when there is one, so an +// unattended signed-in browser cannot be used to take the account over. Setting +// somebody else's is how an admin gives a user their first password, and like +// the other user endpoints it is open to any authenticated caller. +// +// Every other session of the target is ended: a password change is what you +// do when you think someone else is signed in. +func handleSetPassword(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 { + Password string `json:"password"` + CurrentPassword string `json:"current_password"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + if msg := validatePassword(req.Password); msg != "" { + respond(w, http.StatusBadRequest, errResp(msg)) + return + } + + var existing sql.NullString + err = db.QueryRowContext(r.Context(), + "SELECT password_hash FROM users WHERE id = ?", id).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + respond(w, http.StatusNotFound, errResp("user not found")) + return + } + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + caller, _ := userFromContext(r.Context()) + if caller.ID == id && existing.Valid && + bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil { + respond(w, http.StatusForbidden, errResp("current password is incorrect")) + return + } + + hash, err := hashPassword(req.Password) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + tx, err := db.BeginTx(r.Context(), nil) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer tx.Rollback() + + if _, err := tx.ExecContext(r.Context(), + "UPDATE users SET password_hash = ? WHERE id = ?", hash, id); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key + if _, err := tx.ExecContext(r.Context(), + "DELETE FROM sessions WHERE user_id = ? AND id != ?", id, keep); 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) + } +} + +// purgeSessions deletes sessions that have expired, from the sweeper. +func purgeSessions(ctx context.Context, db *sql.DB) { + res, err := db.ExecContext(ctx, + "DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix()) + if err != nil { + log.Printf("sweeper: purge sessions: %v", err) + return + } + if n, _ := res.RowsAffected(); n > 0 { + log.Printf("sweeper: purged %d expired session(s)", n) + } +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..7240fc8 --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,360 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strings" + "testing" + + "git.ryuvia.com/niklas/terdut-server/internal/api" + "git.ryuvia.com/niklas/terdut-server/internal/db" +) + +const adminPassword = "correct horse battery" + +// browser is an HTTP client with its own cookie jar, standing in for one +// signed-in browser. +type browser struct { + *http.Client + base string +} + +func newBrowser(t *testing.T, base string) *browser { + t.Helper() + jar, _ := cookiejar.New(nil) + return &browser{Client: &http.Client{Jar: jar}, base: base} +} + +// do sends a request the way the web UI's own fetch would: same-origin, with +// the cookie from the jar. +func (b *browser) do(t *testing.T, method, path string, body any, header ...string) *http.Response { + t.Helper() + var r io.Reader + if body != nil { + data, _ := json.Marshal(body) + r = bytes.NewReader(data) + } + req, _ := http.NewRequest(method, b.base+path, r) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Sec-Fetch-Site", "same-origin") + for i := 0; i+1 < len(header); i += 2 { + req.Header.Set(header[i], header[i+1]) + } + resp, err := b.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func (b *browser) login(t *testing.T, username, password string) *http.Response { + t.Helper() + return b.do(t, http.MethodPost, "/api/login", map[string]string{"username": username, "password": password}) +} + +// setAdminPassword gives the bootstrapped admin a password over its API key. +func setAdminPassword(t *testing.T, s *ts) { + t.Helper() + resp := s.req(t, http.MethodPut, "/api/users/1/password", map[string]string{"password": adminPassword}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("set password: %d", resp.StatusCode) + } +} + +func signedIn(t *testing.T, s *ts) *browser { + t.Helper() + setAdminPassword(t, s) + b := newBrowser(t, s.URL) + resp := b.login(t, "admin", adminPassword) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d", resp.StatusCode) + } + return b +} + +func status(t *testing.T, resp *http.Response) int { + t.Helper() + resp.Body.Close() + return resp.StatusCode +} + +func TestLogin_SetsSessionCookie(t *testing.T) { + s := newTS(t) + setAdminPassword(t, s) + b := newBrowser(t, s.URL) + + resp := b.login(t, "admin", adminPassword) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d", resp.StatusCode) + } + var cookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "terdut_session" { + cookie = c + } + } + if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode { + t.Fatalf("expected an HttpOnly, SameSite=Lax session cookie, got %+v", cookie) + } + if cookie.Secure { + t.Error("cookie is Secure on a plain-HTTP server with no https public URL") + } + var me struct { + User struct { + Username string `json:"username"` + } `json:"user"` + HasPassword bool `json:"has_password"` + } + decode(t, resp, &me) + if me.User.Username != "admin" || !me.HasPassword { + t.Errorf("unexpected login response %+v", me) + } +} + +func TestLogin_CookieAuthenticatesAPI(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + + if code := status(t, b.do(t, http.MethodGet, "/api/incidents", nil)); code != http.StatusOK { + t.Errorf("GET /api/incidents with cookie: %d", code) + } + resp := b.do(t, http.MethodGet, "/api/me", nil) + var me struct { + User struct { + ID int64 `json:"id"` + } `json:"user"` + } + decode(t, resp, &me) + if me.User.ID != 1 { + t.Errorf("/api/me returned user %d", me.User.ID) + } +} + +func TestLogin_SecureCookieBehindHTTPSPublicURL(t *testing.T) { + s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"}) + setAdminPassword(t, s) + resp := newBrowser(t, s.URL).login(t, "admin", adminPassword) + resp.Body.Close() + for _, c := range resp.Cookies() { + if c.Name == "terdut_session" && !c.Secure { + t.Error("cookie should be Secure when the public URL is https") + } + } +} + +func TestLogin_WrongPasswordAndUnknownUser(t *testing.T) { + s := newTS(t) + setAdminPassword(t, s) + b := newBrowser(t, s.URL) + + if code := status(t, b.login(t, "admin", "not the password")); code != http.StatusUnauthorized { + t.Errorf("wrong password: %d", code) + } + if code := status(t, b.login(t, "nobody", adminPassword)); code != http.StatusUnauthorized { + t.Errorf("unknown user: %d", code) + } +} + +func TestLogin_UserWithoutPasswordCannotSignIn(t *testing.T) { + s := newTS(t) + b := newBrowser(t, s.URL) + // The empty password must not match a user that has none. + if code := status(t, b.login(t, "admin", "")); code != http.StatusUnauthorized { + t.Errorf("login without a password set: %d", code) + } +} + +func TestLogin_RateLimitedPerUsername(t *testing.T) { + s := newTS(t) + setAdminPassword(t, s) + b := newBrowser(t, s.URL) + + for i := range 10 { + if code := status(t, b.login(t, "admin", "wrong")); code != http.StatusUnauthorized { + t.Fatalf("attempt %d: %d", i+1, code) + } + } + // Even the right password is refused once the limit is reached. + resp := b.login(t, "admin", adminPassword) + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %d", resp.StatusCode) + } + if resp.Header.Get("Retry-After") == "" { + t.Error("429 without Retry-After") + } + resp.Body.Close() +} + +func TestSession_CrossOriginWriteRejected(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + + code := status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil, + "Sec-Fetch-Site", "cross-site", "Origin", "https://evil.example")) + if code != http.StatusForbidden { + t.Errorf("cross-origin POST with cookie: %d, want 403", code) + } + + // The same request from the page itself gets through to the handler. + code = status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil)) + if code != http.StatusNotFound { + t.Errorf("same-origin POST with cookie: %d, want 404 from the handler", code) + } +} + +func TestSession_BearerIgnoresOriginChecks(t *testing.T) { + s := newTS(t) + req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/incidents/999/acknowledge", nil) + req.Header.Set("Authorization", "Bearer "+s.key) + req.Header.Set("Sec-Fetch-Site", "cross-site") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if code := status(t, resp); code != http.StatusNotFound { + t.Errorf("Bearer request: %d, want 404 from the handler", code) + } +} + +func TestLogout_EndsSession(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + + if code := status(t, b.do(t, http.MethodPost, "/api/logout", nil)); code != http.StatusNoContent { + t.Fatalf("logout: %d", code) + } + if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized { + t.Errorf("after logout: %d", code) + } + var n int + s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n) + if n != 0 { + t.Errorf("%d session(s) left after logout", n) + } +} + +func TestSession_ExpiredIsRejected(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + s.exec(t, "UPDATE sessions SET expires_at = 1") + + if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized { + t.Errorf("expired session: %d", code) + } + api.Sweep(t.Context(), s.db, 0, 0, api.DeadmanConfig{}, api.NotifyConfig{}) + var n int + s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n) + if n != 0 { + t.Errorf("sweep left %d expired session(s)", n) + } +} + +func TestSetPassword_OwnNeedsCurrent(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + + code := status(t, b.do(t, http.MethodPut, "/api/users/1/password", + map[string]string{"password": "a brand new secret", "current_password": "wrong"})) + if code != http.StatusForbidden { + t.Errorf("wrong current password: %d", code) + } + code = status(t, b.do(t, http.MethodPut, "/api/users/1/password", + map[string]string{"password": "short", "current_password": adminPassword})) + if code != http.StatusBadRequest { + t.Errorf("too-short password: %d", code) + } + code = status(t, b.do(t, http.MethodPut, "/api/users/1/password", + map[string]string{"password": "a brand new secret", "current_password": adminPassword})) + if code != http.StatusNoContent { + t.Fatalf("change password: %d", code) + } + if code := status(t, newBrowser(t, s.URL).login(t, "admin", "a brand new secret")); code != http.StatusOK { + t.Errorf("login with the new password: %d", code) + } +} + +func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) { + s := newTS(t) + phone := signedIn(t, s) + laptop := newBrowser(t, s.URL) + status(t, laptop.login(t, "admin", adminPassword)) + + code := status(t, phone.do(t, http.MethodPut, "/api/users/1/password", + map[string]string{"password": "a brand new secret", "current_password": adminPassword})) + if code != http.StatusNoContent { + t.Fatalf("change password: %d", code) + } + if code := status(t, phone.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusOK { + t.Errorf("the session that changed the password: %d", code) + } + if code := status(t, laptop.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized { + t.Errorf("the other session: %d", code) + } +} + +func TestBootstrap_WithPassword(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(database); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, api.DeadmanConfig{})) + t.Cleanup(func() { srv.Close(); database.Close() }) + + body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}` + resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if code := status(t, resp); code != http.StatusCreated { + t.Fatalf("bootstrap: %d", code) + } + if code := status(t, newBrowser(t, srv.URL).login(t, "admin", adminPassword)); code != http.StatusOK { + t.Errorf("login after bootstrap: %d", code) + } +} + +func TestRouter_UnknownAPIPathIsJSON404(t *testing.T) { + s := newTS(t) + resp, err := http.Get(s.URL + "/api/nope") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + t.Errorf("GET /api/nope: %d %s", resp.StatusCode, resp.Header.Get("Content-Type")) + } +} + +func TestRouter_DeepLinkServesWebUI(t *testing.T) { + s := newTS(t) + resp, err := http.Get(s.URL + "/incidents/1") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") { + t.Errorf("GET /incidents/1: %d %s", resp.StatusCode, resp.Header.Get("Content-Type")) + } + if resp.Header.Get("Content-Security-Policy") == "" { + t.Error("web UI served without a CSP") + } + + resp2, err := http.Get(s.URL + "/js/missing.js") + if err != nil { + t.Fatal(err) + } + if code := status(t, resp2); code != http.StatusNotFound { + t.Errorf("missing asset: %d", code) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index df66db2..0a3a17c 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -14,50 +14,130 @@ import ( type contextKey string -const ctxUser contextKey = "user" +const ( + ctxUser contextKey = "user" + ctxSession contextKey = "session" +) +// AuthMiddleware accepts either of the two credentials the server issues: an +// API key in an Authorization header (the TUI, scripts) or a session cookie +// (the web UI). A request carrying a Bearer header is judged on that alone and +// never falls back to the cookie. +// +// Only the cookie needs a CSRF guard. A browser attaches it to requests other +// sites make, whereas an Authorization header is only ever set by the client +// that holds the key. func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler { + crossOrigin := http.NewCrossOriginProtection() + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok || token == "" { - respond(w, http.StatusUnauthorized, errResp("unauthorized")) + if header := r.Header.Get("Authorization"); header != "" { + token, ok := strings.CutPrefix(header, "Bearer ") + if !ok || token == "" { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + userID, ok := apiKeyUser(r.Context(), db, token) + if !ok { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + serveAs(w, r, next, db, userID, 0) return } - h := sha256.Sum256([]byte(token)) - hash := hex.EncodeToString(h[:]) - - var keyID, userID int64 - err := db.QueryRowContext(r.Context(), - "SELECT id, user_id FROM api_keys WHERE key_hash = ?", hash, - ).Scan(&keyID, &userID) - if err != nil { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { respond(w, http.StatusUnauthorized, errResp("unauthorized")) return } - - // best-effort; don't fail the request if this update fails - db.ExecContext(r.Context(), - "UPDATE api_keys SET last_used_at = ? WHERE id = ?", - time.Now().Unix(), keyID) - - var u models.User - var createdUnix int64 - if err := db.QueryRowContext(r.Context(), - "SELECT id, username, email, created_at FROM users WHERE id = ?", userID, - ).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil { + sessionID, userID, ok := sessionUser(r.Context(), db, c.Value) + if !ok { respond(w, http.StatusUnauthorized, errResp("unauthorized")) return } - u.CreatedAt = time.Unix(createdUnix, 0).UTC() - - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) + if err := crossOrigin.Check(r); err != nil { + respond(w, http.StatusForbidden, errResp("cross-origin request rejected")) + return + } + serveAs(w, r, next, db, userID, sessionID) }) } } +// apiKeyUser resolves an API key to its user and stamps its last use. +func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) { + var keyID, userID int64 + err := db.QueryRowContext(ctx, + "SELECT id, user_id FROM api_keys WHERE key_hash = ?", hashToken(token), + ).Scan(&keyID, &userID) + if err != nil { + return 0, false + } + + // best-effort; don't fail the request if this update fails + db.ExecContext(ctx, + "UPDATE api_keys SET last_used_at = ? WHERE id = ?", + time.Now().Unix(), keyID) + return userID, true +} + +// sessionUser resolves a session token to its session and user. The expiry +// slides forward with use, but at most once per sessionTouchEvery, so a page +// that polls does not write to the database on every request. +func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) { + now := time.Now() + var lastSeen int64 + err := db.QueryRowContext(ctx, ` + SELECT id, user_id, last_seen_at FROM sessions + WHERE token_hash = ? AND expires_at > ?`, + hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen) + if err != nil { + return 0, 0, false + } + + if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery { + db.ExecContext(ctx, + "UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?", + now.Unix(), now.Add(sessionTTL).Unix(), sessionID) + } + return sessionID, userID, true +} + +// serveAs loads the user and hands the request on with it in the context. +// sessionID is zero for API-key requests. +func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) { + var u models.User + var createdUnix int64 + if err := db.QueryRowContext(r.Context(), + "SELECT id, username, email, created_at FROM users WHERE id = ?", userID, + ).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + u.CreatedAt = time.Unix(createdUnix, 0).UTC() + + ctx := context.WithValue(r.Context(), ctxUser, u) + if sessionID != 0 { + ctx = context.WithValue(ctx, ctxSession, sessionID) + } + next.ServeHTTP(w, r.WithContext(ctx)) +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + func userFromContext(ctx context.Context) (models.User, bool) { u, ok := ctx.Value(ctxUser).(models.User) return u, ok } + +// sessionFromContext returns the id of the session a request was authenticated +// with, or false for an API-key request. +func sessionFromContext(ctx context.Context) (int64, bool) { + id, ok := ctx.Value(ctxSession).(int64) + return id, ok +} diff --git a/internal/api/notifier.go b/internal/api/notifier.go index 45f0f81..7bf50d4 100644 --- a/internal/api/notifier.go +++ b/internal/api/notifier.go @@ -359,7 +359,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify msg := ntfyMessage{Topic: n.topic} if cfg.PublicURL != "" { - msg.Click = fmt.Sprintf("%s/api/incidents/%d", + // The web UI's page for the incident, so tapping the notification + // opens something a browser can use. + msg.Click = fmt.Sprintf("%s/incidents/%d", strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID) } diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go index 51c15a2..cb661ef 100644 --- a/internal/api/notify_test.go +++ b/internal/api/notify_test.go @@ -175,7 +175,7 @@ func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) { if !strings.Contains(m.Message, "severity critical") { t.Errorf("expected the severity in %q", m.Message) } - if m.Click != "https://terdut.example.com/api/incidents/1" { + if m.Click != "https://terdut.example.com/incidents/1" { t.Errorf("unexpected click target %q", m.Click) } if len(m.Actions) != 1 || m.Actions[0].Label != "Acknowledge" { diff --git a/internal/api/router.go b/internal/api/router.go index fc9341a..f1450f4 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/web" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) @@ -29,14 +30,21 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman)) r.Post("/api/notify/ack/{token}", handleNotifyAck(db)) + // Signing in to the web UI. Login trades a password for a session cookie, + // which AuthMiddleware accepts in place of an API key. + r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL)) + r.Post("/api/logout", handleLogout(db, notify.PublicURL)) + // All other /api routes require a valid API key. r.Group(func(r chi.Router) { r.Use(AuthMiddleware(db)) + r.Get("/api/me", handleMe(db)) r.Get("/api/users", handleListUsers(db)) r.Post("/api/users", handleCreateUser(db)) r.Delete("/api/users/{id}", handleDeleteUser(db)) r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db)) + r.Put("/api/users/{id}/password", handleSetPassword(db)) r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db)) @@ -72,5 +80,18 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand r.Get("/api/stats/alerts/by-day", handleStatsByDay(db)) }) + // Anything else under /api is a mistake in a client, and should say so in + // JSON rather than get the web UI's HTML. + r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respond(w, http.StatusNotFound, errResp("not found")) + })) + + // Everything outside /api is the web UI. + site, err := web.Handler() + if err != nil { + panic(err) // the site is embedded at build time; this cannot fail at runtime + } + r.Handle("/*", site) + return r } diff --git a/internal/api/users.go b/internal/api/users.go index d22404a..8338949 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -20,6 +20,9 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { var req struct { Username string `json:"username"` Email string `json:"email"` + // Password is optional; without one the first user can only use the + // API key until somebody sets it. + Password string `json:"password"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) @@ -29,6 +32,19 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("username and email are required")) return } + var passwordHash *string + if req.Password != "" { + if msg := validatePassword(req.Password); msg != "" { + respond(w, http.StatusBadRequest, errResp(msg)) + return + } + h, err := hashPassword(req.Password) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + passwordHash = &h + } var count int if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil { @@ -41,7 +57,8 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { } res, err := db.ExecContext(r.Context(), - "INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email) + "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)", + req.Username, req.Email, passwordHash) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return diff --git a/internal/db/migrations/010_web_auth.sql b/internal/db/migrations/010_web_auth.sql new file mode 100644 index 0000000..c8b685e --- /dev/null +++ b/internal/db/migrations/010_web_auth.sql @@ -0,0 +1,18 @@ +-- A password is what lets a person sign in to the web UI. NULL means the user +-- has none and can only use API keys, which is every user created before this. +ALTER TABLE users ADD COLUMN password_hash TEXT; + +-- A session is a browser's credential, the cookie counterpart of an API key: +-- only the hash of the token is stored. expires_at slides forward while the +-- session is in use, so an on-call phone stays signed in. +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + user_agent TEXT +); + +CREATE INDEX idx_sessions_user ON sessions(user_id); diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..bdc325f --- /dev/null +++ b/internal/web/static/app.css @@ -0,0 +1,622 @@ +/* terdut web UI. + * + * Mobile first: one column, a top bar and a bottom tab bar. From 900px the tab + * bar becomes a sidebar and the queue shows list and detail side by side. + * Colour is reserved for severity and status; everything else is neutral. + */ + +:root { + --bg: #f5f6f8; + --surface: #ffffff; + --surface-2: #eff1f4; + --surface-hover: #f7f8fa; + --border: #e2e5ea; + --border-strong: #cfd3da; + --text: #16181d; + --muted: #5b626e; + --faint: #8a909b; + + --accent: #2f5bd3; + --accent-text: #ffffff; + --accent-soft: #e8eefc; + + --crit: #d0342c; + --crit-soft: #fdecea; + --warn: #b86e00; + --warn-soft: #fdf3e1; + --info: #2f6fdf; + --info-soft: #e9f0fd; + --ok: #1d7f4c; + --ok-soft: #e6f5ec; + --snooze: #6b5bd2; + --snooze-soft: #efedfb; + + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); + --shadow-lg: 0 12px 32px rgb(16 24 40 / 18%); + + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --topbar-h: 52px; + --tabbar-h: 58px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1115; + --surface: #171a20; + --surface-2: #1f232b; + --surface-hover: #1c2027; + --border: #2a2f38; + --border-strong: #394050; + --text: #e7e9ed; + --muted: #a0a7b3; + --faint: #737a87; + + --accent: #6d8ff0; + --accent-text: #0b0d12; + --accent-soft: #1d2640; + + --crit: #ff6b61; + --crit-soft: #3a1c1b; + --warn: #f0b140; + --warn-soft: #362a14; + --info: #74a3ff; + --info-soft: #1a2640; + --ok: #4cc488; + --ok-soft: #15301f; + --snooze: #a89bff; + --snooze-soft: #262245; + + --shadow: 0 1px 2px rgb(0 0 0 / 40%); + --shadow-lg: 0 16px 40px rgb(0 0 0 / 55%); + } +} + +*, *::before, *::after { box-sizing: border-box; } +[hidden] { display: none !important; } + +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.45 var(--font); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +a { color: inherit; text-decoration: none; } +button, input, textarea, select { font: inherit; color: inherit; } +code { font-family: var(--mono); font-size: 0.9em; } +h1, h2, h3 { margin: 0; line-height: 1.25; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ---------- boot + login ---------- */ + +.boot { display: grid; place-items: center; min-height: 100dvh; } +.spinner { + width: 26px; height: 26px; border-radius: 50%; + border: 3px solid var(--border); border-top-color: var(--accent); + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.login { + min-height: 100dvh; + display: grid; place-items: center; + padding: calc(24px + var(--safe-top)) 16px calc(24px + var(--safe-bottom)); +} +.login-card { + width: 100%; max-width: 360px; + display: grid; gap: 14px; +} +.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; } +.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; } + +label { display: grid; gap: 6px; } +label > span { font-size: 13px; font-weight: 600; color: var(--muted); } + +input, textarea, select { + width: 100%; + min-height: 44px; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: 16px; /* 16px stops iOS zooming into the field */ +} +textarea { min-height: 110px; resize: vertical; line-height: 1.45; } +input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + +.form-error { + margin: 0; padding: 10px 12px; + background: var(--crit-soft); color: var(--crit); + border-radius: var(--radius-sm); font-size: 14px; +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 44px; padding: 0 16px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + font-weight: 600; font-size: 15px; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s, border-color 0.12s, opacity 0.12s; +} +.btn:hover { background: var(--surface-hover); } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.btn-primary:hover { background: var(--accent); filter: brightness(1.06); } +.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; } +.btn-danger:hover { background: var(--crit); filter: brightness(1.06); } +.btn-ghost { background: transparent; border-color: transparent; } +.btn-ghost:hover { background: var(--surface-2); } +.btn-block { width: 100%; } +.btn-icon { width: 44px; padding: 0; } +.btn-sm { min-height: 32px; padding: 0 8px; font-size: 13px; } +.btn-sm svg { width: 16px; height: 16px; } +.btn svg, .icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +/* ---------- app frame ---------- */ + +.app { min-height: 100dvh; } + +.topbar { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; gap: 12px; + height: calc(var(--topbar-h) + var(--safe-top)); + padding: var(--safe-top) 16px 0; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: saturate(1.4) blur(12px); + -webkit-backdrop-filter: saturate(1.4) blur(12px); + border-bottom: 1px solid var(--border); +} +.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; } + +.open-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 10px; border-radius: 999px; + font-size: 13px; font-weight: 600; + background: var(--surface-2); color: var(--muted); +} +.open-pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); } +.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); } +.open-pill.has-triggered::before { background: var(--crit); } +.open-pill.all-acked::before { background: var(--warn); } + +/* Bottom tab bar on phones. */ +.nav { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; + display: grid; grid-template-columns: repeat(4, 1fr); + height: calc(var(--tabbar-h) + var(--safe-bottom)); + padding-bottom: var(--safe-bottom); + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: saturate(1.4) blur(12px); + -webkit-backdrop-filter: saturate(1.4) blur(12px); + border-top: 1px solid var(--border); +} +.nav-brand { display: none; } +.nav-link { + position: relative; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; + color: var(--faint); font-size: 11px; font-weight: 600; +} +.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.nav-link[aria-current="page"] { color: var(--accent); } +.nav-badge { + position: absolute; top: 6px; left: calc(50% + 6px); + min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 999px; background: var(--crit); color: #fff; + font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; +} + +.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); } +.view-page { padding-left: 16px; padding-right: 16px; } +.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; } + +/* Phone detail: the incident takes the whole screen with its own action bar, + so the tab bar and top bar step aside. */ +.app.detail-open .nav, +.app.detail-open .topbar { display: none; } +.app.detail-open .pane-list { display: none; } +.app.detail-open .view-queue { padding-bottom: 0; } +.view-queue:not(.has-detail) .pane-detail { display: none; } + +/* ---------- chips ---------- */ + +.chips { + display: flex; gap: 6px; + padding: 12px 16px 8px; + overflow-x: auto; scrollbar-width: none; +} +.chips::-webkit-scrollbar { display: none; } +.chip { + flex: none; + min-height: 34px; padding: 0 12px; + border: 1px solid var(--border-strong); border-radius: 999px; + background: var(--surface); color: var(--muted); + font-size: 13px; font-weight: 600; cursor: pointer; +} +.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); } +.chip .count { margin-left: 4px; opacity: 0.7; } + +/* ---------- lists ---------- */ + +.list { padding: 0 16px 16px; display: grid; gap: 8px; } + +.row { + position: relative; + display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; + padding: 11px 14px 11px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + cursor: pointer; + overflow: hidden; +} +.row:hover { background: var(--surface-hover); } +.row[aria-current="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.row.kbd-focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.row::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--sev, var(--border-strong)); +} +.row-title { + font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; } +.row-meta { + grid-column: 1 / -1; + display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; + color: var(--muted); font-size: 13px; + min-width: 0; +} +.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); } +.row.resolved .row-title { color: var(--muted); } + +.sev-critical { --sev: var(--crit); } +.sev-warning { --sev: var(--warn); } +.sev-info { --sev: var(--info); } + +.empty { + padding: 48px 16px; text-align: center; color: var(--muted); +} +.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; } +.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; } + +.load-error { + margin: 12px 16px; padding: 10px 12px; + background: var(--crit-soft); color: var(--crit); + border-radius: var(--radius-sm); font-size: 14px; +} + +/* ---------- badges ---------- */ + +.badge { + display: inline-flex; align-items: center; gap: 5px; + padding: 1px 8px; border-radius: 999px; + font-size: 12px; font-weight: 700; letter-spacing: 0.01em; + background: var(--surface-2); color: var(--muted); + white-space: nowrap; +} +.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.badge.plain::before { display: none; } +.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); } +.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); } +.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); } +.badge.st-resolved { background: var(--ok-soft); color: var(--ok); } +.badge.sev-critical { background: var(--crit-soft); color: var(--crit); } +.badge.sev-warning { background: var(--warn-soft); color: var(--warn); } +.badge.sev-info { background: var(--info-soft); color: var(--info); } + +/* ---------- incident detail ---------- */ + +.detail { padding: 0 16px calc(96px + var(--safe-bottom)); } +.detail-head { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 4px; + height: calc(var(--topbar-h) + var(--safe-top)); + margin: 0 -16px; padding: var(--safe-top) 8px 0; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: saturate(1.4) blur(12px); + -webkit-backdrop-filter: saturate(1.4) blur(12px); + border-bottom: 1px solid var(--border); +} +.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; } +.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; } +.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.card + .card, .section + .section { margin-top: 14px; } +.card-pad { padding: 14px; } + +.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; padding: 14px; font-size: 14px; } +.facts dt { color: var(--muted); } +.facts dd { margin: 0; overflow-wrap: anywhere; } +.facts .sub { color: var(--faint); } + +.section { margin-top: 22px; } +.section-title { + display: flex; align-items: baseline; justify-content: space-between; gap: 8px; + font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted); margin: 0 2px 8px; +} + +.labels-wrap { display: flex; flex-wrap: wrap; gap: 6px; } +.label { + display: inline-flex; max-width: 100%; + font-family: var(--mono); font-size: 12px; + border: 1px solid var(--border); border-radius: var(--radius-sm); + overflow: hidden; +} +.label > span { padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.label > span:first-child { background: var(--surface-2); color: var(--muted); } + +.alert-item { padding: 12px 14px; display: grid; gap: 6px; } +.alert-item + .alert-item { border-top: 1px solid var(--border); } +.alert-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.alert-item-name { font-weight: 650; overflow-wrap: anywhere; } +.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } +.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); } +.alert-item-foot a { color: var(--accent); font-weight: 600; } +details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; } +details > summary::-webkit-details-marker { display: none; } +details > summary::before { content: "▸ "; } +details[open] > summary::before { content: "▾ "; } +details[open] > summary { margin-bottom: 8px; } + +.timeline { list-style: none; margin: 0; padding: 4px 0; } +.tl-item { + position: relative; + display: grid; grid-template-columns: 20px 1fr; gap: 10px; + padding: 8px 14px; +} +.tl-item::before { + content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border); +} +.tl-item:first-child::before { top: 16px; } +.tl-item:last-child::before { bottom: calc(100% - 16px); } +.tl-dot { + position: relative; z-index: 1; + width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%; + background: var(--surface); border: 2px solid var(--faint); +} +.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); } +.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); } +.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); } +.tl-snoozed .tl-dot { border-color: var(--snooze); } +.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); } +.tl-body { min-width: 0; font-size: 14px; } +.tl-text { overflow-wrap: anywhere; } +.tl-text .who { font-weight: 650; } +.tl-time { color: var(--faint); font-size: 12px; } +.tl-note .note { + margin-top: 6px; padding: 10px 12px; + background: var(--surface-2); border-radius: var(--radius-sm); + white-space: pre-wrap; overflow-wrap: anywhere; +} +.note-actions { display: flex; justify-content: flex-end; } +.note-actions .btn { color: var(--muted); } + +/* The action bar sits at the bottom of the screen on a phone, and at the + bottom of the detail pane on desktop. */ +.actionbar { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 15; + display: flex; gap: 8px; + padding: 10px 16px calc(10px + var(--safe-bottom)); + background: var(--surface); + border-top: 1px solid var(--border); +} +.actionbar .btn { min-height: 48px; } +.actionbar .btn-primary { flex: 1; font-size: 16px; } + +.detail-placeholder { + display: grid; place-items: center; height: 100%; + color: var(--faint); text-align: center; padding: 32px; +} + +/* ---------- sheet (dialog) ---------- */ + +.sheet { + width: 100%; max-width: 100%; + max-height: 88dvh; + margin: auto 0 0; padding: 0; + border: 0; border-radius: 16px 16px 0 0; + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-lg); + overflow: auto; +} +.sheet::backdrop { background: rgb(0 0 0 / 40%); } +.sheet[open] { animation: sheet-up 0.18s ease-out; } +@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } } +.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); } +.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); } +.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; } +.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; } +.sheet-form { display: grid; gap: 12px; } +.sheet-actions { display: flex; gap: 8px; margin-top: 16px; } +.sheet-actions .btn { flex: 1; } + +.menu { list-style: none; margin: 0 -4px; padding: 0; } +.menu-item { + display: flex; align-items: center; gap: 12px; + width: 100%; min-height: 50px; padding: 0 12px; + background: none; border: 0; border-radius: var(--radius-sm); + text-align: left; font-size: 16px; cursor: pointer; +} +.menu-item:hover { background: var(--surface-2); } +.menu-item .icon { color: var(--muted); flex: none; } +.menu-item .menu-sub { margin-left: auto; color: var(--faint); font-size: 13px; } +.menu-item.danger, .menu-item.danger .icon { color: var(--crit); } +.menu-item[aria-checked="true"] { font-weight: 700; } +.menu-item[aria-checked="true"]::after { content: "✓"; margin-left: 8px; color: var(--accent); } +.menu-sep { height: 1px; background: var(--border); margin: 6px 12px; } + +/* ---------- toast ---------- */ + +.toast { + position: fixed; left: 50%; z-index: 50; + bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px); + transform: translateX(-50%); + max-width: calc(100% - 32px); + padding: 10px 16px; border-radius: var(--radius); + background: var(--text); color: var(--bg); + font-size: 14px; font-weight: 600; + box-shadow: var(--shadow-lg); +} +.toast.error { background: var(--crit); color: #fff; } +.app.detail-open ~ .toast { bottom: calc(80px + var(--safe-bottom)); } + +/* ---------- on-call ---------- */ + +.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; } +.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } + +.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; } +.avatar { + flex: none; display: grid; place-items: center; + width: 44px; height: 44px; border-radius: 50%; + background: var(--accent-soft); color: var(--accent); + font-weight: 750; font-size: 17px; text-transform: uppercase; +} +.avatar.none { background: var(--surface-2); color: var(--faint); } +.now-label { color: var(--muted); font-size: 13px; font-weight: 600; } +.now-name { font-size: 20px; font-weight: 750; } +.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; } + +.week-nav { display: flex; align-items: center; gap: 4px; } +.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; } +.days { list-style: none; margin: 0; padding: 0; } +.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; } +.day + .day { border-top: 1px solid var(--border); } +.day-name { font-weight: 650; } +.day-date { color: var(--faint); font-size: 13px; } +.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.day-who.nobody { color: var(--faint); font-style: italic; } +.day.today { background: var(--accent-soft); } +.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; } +.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); } +.day.today .day-name { color: var(--accent); } +.day.past { opacity: 0.6; } + +.shift-list { list-style: none; margin: 0; padding: 0; } +.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; } +.shift-list li + li { border-top: 1px solid var(--border); } +.shift-list .muted { color: var(--faint); } + +/* ---------- alerts page ---------- */ + +.view-alerts .chips { padding-left: 0; padding-right: 0; } +.view-alerts .list { padding-left: 0; padding-right: 0; } +.row.st-firing { --sev: var(--crit); } +.row.st-resolved { --sev: var(--ok); } +.row.no-link { cursor: default; } + +/* ---------- account ---------- */ + +.account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; } +.account-name { font-size: 18px; font-weight: 750; } +.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } +.pw-form { display: grid; gap: 12px; padding: 16px; } +.form-ok { + margin: 0; padding: 10px 12px; + background: var(--ok-soft); color: var(--ok); + border-radius: var(--radius-sm); font-size: 14px; +} +.kbd-table { width: 100%; border-collapse: collapse; font-size: 14px; } +.kbd-table td { padding: 8px 14px; border-top: 1px solid var(--border); } +.kbd-table tr:first-child td { border-top: 0; } +kbd { + display: inline-block; min-width: 1.6em; padding: 1px 6px; + font-family: var(--mono); font-size: 12px; text-align: center; + background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom-width: 2px; + border-radius: 4px; +} +.only-desktop { display: none; } +.foot-note { color: var(--faint); font-size: 13px; text-align: center; margin: 24px auto; } + +/* ---------- desktop ---------- */ + +@media (min-width: 900px) { + :root { --tabbar-h: 0px; } + + .app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; } + + .nav { + position: static; grid-row: 1 / span 2; + display: flex; flex-direction: column; gap: 2px; + height: auto; padding: 16px 12px; + background: var(--surface); + border-top: 0; border-right: 1px solid var(--border); + backdrop-filter: none; + } + .nav-brand { + display: flex; align-items: center; gap: 10px; + padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em; + } + .nav-link { + flex-direction: row; justify-content: flex-start; gap: 12px; + min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); + color: var(--muted); font-size: 14px; + } + .nav-link:hover { background: var(--surface-2); } + .nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); } + .nav-link svg { width: 20px; height: 20px; } + .nav-badge { position: static; margin-left: auto; } + + .topbar { display: none; } + .view { padding-bottom: 0; overflow: auto; height: 100dvh; } + .view-page { padding: 8px 32px 32px; } + + .view-queue { + display: grid; grid-template-columns: minmax(340px, 420px) 1fr; + overflow: hidden; + } + .view-queue .pane { overflow: auto; height: 100dvh; } + .pane-list { border-right: 1px solid var(--border); } + .pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; } + .view-queue:not(.has-detail) .pane-detail { display: block; } + + /* On desktop the list stays visible next to the detail. */ + .app.detail-open .nav { display: flex; } + .app.detail-open .pane-list { display: block; } + .detail-head .back { display: none; } + .detail-head { padding-left: 16px; } + + .pane-detail { position: relative; display: flex; flex-direction: column; } + .detail { flex: 1; padding: 0 32px 24px; max-width: 900px; width: 100%; } + .detail-head { margin: 0 -32px; padding-left: 32px; } + .actionbar { + position: sticky; bottom: 0; + padding: 12px 32px; + } + .actionbar .btn-primary { flex: 0 1 240px; } + + .sheet { + width: min(440px, calc(100% - 32px)); + margin: auto; border-radius: 14px; + } + .sheet-grab { display: none; } + .sheet-inner { padding: 20px; } + + .toast, .app.detail-open ~ .toast { bottom: 24px; } + .only-desktop { display: block; } +} diff --git a/internal/web/static/apple-touch-icon.png b/internal/web/static/apple-touch-icon.png new file mode 100644 index 0000000..2fa2960 Binary files /dev/null and b/internal/web/static/apple-touch-icon.png differ diff --git a/internal/web/static/icon-192.png b/internal/web/static/icon-192.png new file mode 100644 index 0000000..788fa64 Binary files /dev/null and b/internal/web/static/icon-192.png differ diff --git a/internal/web/static/icon-512.png b/internal/web/static/icon-512.png new file mode 100644 index 0000000..c554665 Binary files /dev/null and b/internal/web/static/icon-512.png differ diff --git a/internal/web/static/icon.svg b/internal/web/static/icon.svg new file mode 100644 index 0000000..7dc43d5 --- /dev/null +++ b/internal/web/static/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..ef269b4 --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + terdut + + + + + + + +
+ +
+ +
+ + + + + + + diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js new file mode 100644 index 0000000..7b42806 --- /dev/null +++ b/internal/web/static/js/account.js @@ -0,0 +1,110 @@ +// Account: who you are signed in as, changing your password, signing out. + +import * as api from './api.js'; +import { h, clear, icon, toast } from './ui.js'; +import { initial } from './format.js'; +import { state } from './state.js'; +import { signOut } from './app.js'; + +const view = () => document.getElementById('view-more'); + +// Rendered once per visit rather than on every poll, so a half-typed password +// is never wiped out from under you. +export function show() { + render(); +} + +function render() { + const { user, has_password: hasPassword } = state.me; + clear(view(), + h('div', { class: 'card account-card' }, + h('div', { class: 'avatar', text: initial(user.username) }), + h('div', {}, + h('div', { class: 'account-name', text: user.username }), + h('div', { class: 'account-email', text: user.email }))), + + h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), + passwordForm(user, hasPassword), + + h('div', { class: 'only-desktop' }, + h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), + h('div', { class: 'card' }, shortcuts())), + + h('div', { class: 'page-head' }), + h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'), + h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }), + ); +} + +function passwordForm(user, hasPassword) { + const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); + const ok = h('p', { class: 'form-ok', role: 'status', hidden: true }); + const current = hasPassword + ? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true }) + : null; + const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' }); + const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' }); + const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' }); + + // A hidden username field lets password managers file the new password + // under the right account. + const form = h('form', { class: 'card pw-form', autocomplete: 'on' }, + h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }), + current && h('label', {}, h('span', { text: 'Current password' }), current), + h('label', {}, h('span', { text: 'New password' }), next), + h('label', {}, h('span', { text: 'Repeat new password' }), again), + err, ok, submit, + ); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + err.hidden = true; + ok.hidden = true; + if (next.value !== again.value) { + err.textContent = 'The new passwords do not match.'; + err.hidden = false; + return; + } + submit.disabled = true; + try { + await api.setPassword(user.id, next.value, current ? current.value : ''); + state.me.has_password = true; + form.reset(); + if (!current) { + // From now on the form needs the current-password field. + render(); + toast('Password saved'); + return; + } + ok.textContent = 'Password saved. Other devices have been signed out.'; + ok.hidden = false; + } catch (ex) { + err.textContent = ex.message; + err.hidden = false; + } finally { + submit.disabled = false; + } + }); + return form; +} + +function shortcuts() { + const rows = [ + ['j / k', 'Move through the queue'], + ['Enter', 'Open incident'], + ['Esc', 'Back to the queue'], + ['f', 'Cycle the queue filter'], + ['a / A', 'Acknowledge / clear acknowledgement'], + ['R', 'Resolve (asks first)'], + ['s', 'Assign'], + ['z / Z', 'Snooze / end snooze'], + ['c', 'Add a note'], + ['x', 'Archive / unarchive a resolved incident'], + ['r', 'Refresh now'], + ]; + return h('table', { class: 'kbd-table' }, + h('tbody', {}, rows.map(([k, v]) => + h('tr', {}, + h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])), + h('td', { text: v }))))); +} diff --git a/internal/web/static/js/alerts.js b/internal/web/static/js/alerts.js new file mode 100644 index 0000000..c53ec2c --- /dev/null +++ b/internal/web/static/js/alerts.js @@ -0,0 +1,91 @@ +// The alert feed: Alertmanager's own records, read-only. Each row leads to the +// incident it belongs to, which is where anything can be done about it. + +import * as api from './api.js'; +import { h, clear, badge, emptyState, spinner } from './ui.js'; +import { age, severityClass, labelSummary } from './format.js'; + +const FILTERS = [ + { id: 'firing', label: 'Firing', query: { status: 'firing' } }, + { id: 'resolved', label: 'Resolved', query: { status: 'resolved' } }, + { id: 'all', label: 'All', query: {} }, + { id: 'archived', label: 'Archived', query: { archived: 'true' } }, +]; + +const view = () => document.getElementById('view-alerts'); + +let filter = 'firing'; +let items = null; +let error = null; + +export function show() { + render(); + refresh(); +} + +export async function refresh() { + const requested = filter; + const f = FILTERS.find((x) => x.id === filter); + try { + const result = await api.alerts({ ...f.query, limit: 200 }); + if (requested !== filter) return; + items = result; + error = null; + } catch (err) { + if (requested !== filter) return; + error = err.message; + } + render(); +} + +function setFilter(id) { + if (id === filter) return; + filter = id; + items = null; + render(); + refresh(); +} + +function render() { + const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Filter' }, + FILTERS.map((f) => h('button', { + class: 'chip', + type: 'button', + role: 'tab', + 'aria-selected': String(f.id === filter), + onclick: () => setFilter(f.id), + text: f.label, + }))); + + let body; + if (error && !items) body = h('div', { class: 'load-error', text: error }); + else if (!items) body = spinner(); + else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null); + else { + body = h('div', { class: 'list' }, + error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), + items.map(row)); + } + clear(view(), h('div', {}, chips, body)); +} + +function row(a) { + const summary = (a.annotations && a.annotations.summary) || ''; + const sev = a.labels && a.labels.severity; + const labels = labelSummary(Object.fromEntries( + Object.entries(a.labels || {}).filter(([k]) => k !== 'severity'))); + const linked = a.incident_id != null; + return h(linked ? 'a' : 'div', { + class: `row st-${a.status} ${linked ? '' : 'no-link'}`, + href: linked ? `/incidents/${a.incident_id}` : null, + }, + h('div', { class: 'row-title', text: a.name }), + h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }), + h('div', { class: 'row-meta' }, + badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`), + sev && badge(sev, `plain ${severityClass(sev)}`), + summary && h('span', { text: summary }), + labels && h('span', { class: 'labels', text: labels }), + ), + ); +} diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js new file mode 100644 index 0000000..33e6096 --- /dev/null +++ b/internal/web/static/js/api.js @@ -0,0 +1,98 @@ +// The terdut-server client. The page is served by the server itself, so every +// call is same-origin and carries the session cookie. + +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +// Called whenever the server says the session is gone, so the app can put the +// login form back up wherever the user happened to be. +let onUnauthorized = () => {}; +export function setUnauthorizedHandler(fn) { + onUnauthorized = fn; +} + +async function call(method, path, { query, body, signal } = {}) { + const url = new URL('/api' + path, location.origin); + for (const [k, v] of Object.entries(query || {})) { + if (v === '' || v == null) continue; + url.searchParams.set(k, v); + } + + const headers = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + + let resp; + try { + resp = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + credentials: 'same-origin', + signal, + }); + } catch (err) { + if (err.name === 'AbortError') throw err; + throw new ApiError(0, 'Cannot reach the server.'); + } + + if (resp.status === 204) return null; + + let data = null; + try { + data = await resp.json(); + } catch { + /* non-JSON body: keep null */ + } + + if (!resp.ok) { + if (resp.status === 401 && path !== '/login') onUnauthorized(); + const message = (data && data.error) || `Server answered ${resp.status}.`; + throw new ApiError(resp.status, message); + } + return data; +} + +// session +export const me = () => call('GET', '/me'); +export const login = (username, password) => call('POST', '/login', { body: { username, password } }); +export const logout = () => call('POST', '/logout'); +export const setPassword = (userID, password, currentPassword) => + call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } }); + +// users +export const users = () => call('GET', '/users'); + +// incidents +export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); +export const incident = (id) => call('GET', `/incidents/${id}`); +export const timeline = (id) => call('GET', `/incidents/${id}/timeline`); + +export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`); +export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`); +export const resolve = (id) => call('POST', `/incidents/${id}/resolve`); +export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } }); +export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec }); +export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`); +export const archive = (id) => call('POST', `/incidents/${id}/archive`); +export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`); +export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } }); +export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`); + +// alerts +export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }); + +// schedule +export const schedule = (from, to) => call('GET', '/schedule', { query: { from, to } }); +export async function onCallNow() { + try { + return await call('GET', '/schedule/current'); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js new file mode 100644 index 0000000..9aa28e2 --- /dev/null +++ b/internal/web/static/js/app.js @@ -0,0 +1,234 @@ +// Entry point: session, routing, badges and keyboard. + +import * as api from './api.js'; +import * as ui from './ui.js'; +import * as poll from './poll.js'; +import { state, reset } from './state.js'; +import * as queue from './queue.js'; +import * as incident from './incident.js'; +import * as oncall from './oncall.js'; +import * as alerts from './alerts.js'; +import * as account from './account.js'; + +const $ = (id) => document.getElementById(id); + +// One route per section; /incidents/{id} is the queue with a detail open. +const SECTIONS = { + queue: { title: 'Queue', view: queue }, + oncall: { title: 'On-call', view: oncall }, + alerts: { title: 'Alerts', view: alerts }, + more: { title: 'Account', view: account }, +}; + +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 }; + return { section: 'queue', incident: null }; +} + +let route = parseRoute(location.pathname); +// How many in-app navigations deep we are, so Back can use the browser's +// history when there is somewhere to go back to, and the queue otherwise. +let depth = 0; +let listScroll = 0; + +export function navigate(path, { replace = false } = {}) { + if (path === location.pathname + location.search) return; + if (replace) { + history.replaceState({ depth }, '', path); + } else { + depth += 1; + history.pushState({ depth }, '', path); + } + render(); +} + +export function back() { + if (depth > 0) history.back(); + else navigate('/', { replace: true }); +} + +window.addEventListener('popstate', (e) => { + depth = (e.state && e.state.depth) || 0; + render(); +}); + +function render() { + const prev = route; + route = parseRoute(location.pathname); + const app = $('app'); + + for (const [name, s] of Object.entries(SECTIONS)) { + const el = $(`view-${name}`); + el.hidden = name !== route.section; + if (name === route.section) $('topbar-title').textContent = s.title; + } + for (const link of document.querySelectorAll('.nav-link')) { + if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } + + const detailOpen = route.section === 'queue' && route.incident != null; + const wasOpen = prev.section === 'queue' && prev.incident != null; + if (detailOpen && !wasOpen) listScroll = window.scrollY; + app.classList.toggle('detail-open', detailOpen); + $('view-queue').classList.toggle('has-detail', detailOpen); + + if (route.section === 'queue') { + queue.show(route.incident); + incident.show(route.incident); + } else { + incident.show(null); + SECTIONS[route.section].view.show(); + } + + if (detailOpen && !wasOpen) window.scrollTo(0, 0); + else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll)); + else if (prev.section !== route.section) window.scrollTo(0, 0); + + updateTitle(); +} + +// ---------- refresh + badges ---------- + +async function refresh() { + state.open = await api.incidents({ sort: 'severity' }); + updateBadges(); + const jobs = []; + if (route.section === 'queue') { + jobs.push(queue.refresh()); + if (route.incident != null) jobs.push(incident.refresh()); + } else { + const v = SECTIONS[route.section].view; + if (v.refresh) jobs.push(v.refresh()); + } + await Promise.allSettled(jobs); +} + +function updateBadges() { + const open = state.open.length; + const triggered = state.open.filter((i) => i.status === 'triggered').length; + + const pill = $('open-pill'); + pill.hidden = false; + pill.textContent = open ? `${open} open` : 'All clear'; + pill.classList.toggle('has-triggered', triggered > 0); + pill.classList.toggle('all-acked', open > 0 && triggered === 0); + + const badge = document.querySelector('[data-badge]'); + badge.hidden = triggered === 0; + badge.textContent = String(triggered); + updateTitle(); +} + +function updateTitle() { + const triggered = state.open.filter((i) => i.status === 'triggered').length; + const section = SECTIONS[route.section].title; + const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`; + document.title = triggered ? `(${triggered}) ${base}` : base; +} + +// ---------- session ---------- + +async function boot() { + ui.initSheet(); + api.setUnauthorizedHandler(showLogin); + document.addEventListener('click', interceptLinks); + document.addEventListener('keydown', onKey); + $('login-form').addEventListener('submit', onLogin); + + try { + state.me = await api.me(); + showApp(); + } catch (err) { + if (err.status === 401) showLogin(); + else showBootError(err); + } +} + +function showBootError(err) { + ui.clear($('boot'), ui.emptyState('Cannot load terdut', err.message)); + $('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' })); +} + +function showLogin() { + poll.stop(); + ui.closeSheet(null); + reset(); + $('boot').hidden = true; + $('app').hidden = true; + $('login').hidden = false; + const form = $('login-form'); + form.querySelector('.form-error').hidden = true; + form.password.value = ''; + (form.username.value ? form.password : form.username).focus(); +} + +async function onLogin(e) { + e.preventDefault(); + const form = e.currentTarget; + const err = form.querySelector('.form-error'); + const btn = form.querySelector('button[type=submit]'); + err.hidden = true; + btn.disabled = true; + try { + state.me = await api.login(form.username.value.trim(), form.password.value); + form.password.value = ''; + showApp(); + } catch (ex) { + err.textContent = ex.message; + err.hidden = false; + } finally { + btn.disabled = false; + } +} + +export async function signOut() { + try { + await api.logout(); + } catch { + /* the cookie is cleared server-side or already gone */ + } + showLogin(); +} + +function showApp() { + $('boot').hidden = true; + $('login').hidden = true; + $('app').hidden = false; + render(); + poll.start(refresh); + poll.now(); +} + +// ---------- links + keys ---------- + +function interceptLinks(e) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const a = e.target.closest('a[href]'); + if (!a || a.target || a.origin !== location.origin || a.pathname.startsWith('/api/')) return; + e.preventDefault(); + navigate(a.pathname + a.search); +} + +function onKey(e) { + if (e.metaKey || e.ctrlKey || e.altKey || ui.sheetIsOpen() || $('app').hidden) return; + const tag = e.target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + + if (e.key === 'r') { + poll.now(); + e.preventDefault(); + return; + } + if (route.section !== 'queue') return; + if (route.incident != null && incident.key(e)) { + e.preventDefault(); + return; + } + if (queue.key(e)) e.preventDefault(); +} + +boot(); diff --git a/internal/web/static/js/format.js b/internal/web/static/js/format.js new file mode 100644 index 0000000..1cbed84 --- /dev/null +++ b/internal/web/static/js/format.js @@ -0,0 +1,110 @@ +// Formatting of times, durations and labels. + +const MIN = 60 * 1000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +// Compact age for list rows: "now", "4m", "3h", "2d". +export function age(iso, now = Date.now()) { + const ms = Math.max(0, now - Date.parse(iso)); + if (ms < MIN) return 'now'; + if (ms < HOUR) return `${Math.floor(ms / MIN)}m`; + if (ms < DAY) return `${Math.floor(ms / HOUR)}h`; + return `${Math.floor(ms / DAY)}d`; +} + +// "4 min ago", "3 h ago", "yesterday"-free: stays unambiguous at 3am. +export function ago(iso, now = Date.now()) { + const a = age(iso, now); + return a === 'now' ? 'just now' : `${a} ago`; +} + +// Time remaining until iso, e.g. "1h 20m". +export function until(iso, now = Date.now()) { + return duration(Date.parse(iso) - now); +} + +export function duration(ms) { + ms = Math.max(0, ms); + if (ms < MIN) return '<1m'; + const d = Math.floor(ms / DAY); + const h = Math.floor((ms % DAY) / HOUR); + const m = Math.floor((ms % HOUR) / MIN); + if (d) return h ? `${d}d ${h}h` : `${d}d`; + if (h) return m ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +const timeFmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }); +const dayTimeFmt = new Intl.DateTimeFormat(undefined, { + weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', +}); + +// Local timestamp; the date is dropped when it is today. +export function when(iso) { + const d = new Date(iso); + const today = new Date(); + if (d.toDateString() === today.toDateString()) return timeFmt.format(d); + return dayTimeFmt.format(d); +} + +export function isFuture(iso) { + return iso != null && Date.parse(iso) > Date.now(); +} + +// Local calendar dates, as the schedule stores them (YYYY-MM-DD). +export function isoDate(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export function mondayOf(d) { + const r = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + r.setDate(r.getDate() - ((r.getDay() + 6) % 7)); + return r; +} + +export function addDays(d, n) { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +} + +// ISO 8601 week number. +export function isoWeek(d) { + const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = t.getUTCDay() || 7; + t.setUTCDate(t.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)); + return Math.ceil(((t - yearStart) / DAY + 1) / 7); +} + +export const STATUS_LABEL = { + triggered: 'Triggered', + acknowledged: 'Acknowledged', + resolved: 'Resolved', + snoozed: 'Snoozed', + firing: 'Firing', +}; + +export function severityClass(sev) { + const s = (sev || '').toLowerCase(); + if (s === 'critical' || s === 'page' || s === 'error') return 'sev-critical'; + if (s === 'warning' || s === 'warn') return 'sev-warning'; + if (s) return 'sev-info'; + return ''; +} + +// A one-line summary of the group labels, without the one the title already shows. +export function labelSummary(labels, skip = 'alertname') { + return Object.entries(labels || {}) + .filter(([k]) => k !== skip) + .map(([k, v]) => `${k}=${v}`) + .join(' · '); +} + +export function initial(name) { + return (name || '?').trim().charAt(0) || '?'; +} diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js new file mode 100644 index 0000000..daea9d8 --- /dev/null +++ b/internal/web/static/js/incident.js @@ -0,0 +1,447 @@ +// Incident detail: facts, member alerts, the timeline with notes, and the +// action bar that carries everything a responder does to an incident. + +import * as api from './api.js'; +import * as poll from './poll.js'; +import { + h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, +} from './ui.js'; +import { + ago, when, until, isFuture, severityClass, STATUS_LABEL, +} from './format.js'; +import { myID, users } from './state.js'; +import { back } from './app.js'; + +const pane = () => document.getElementById('detail'); + +let currentID = null; +let inc = null; +let events = []; +let error = null; +let busy = false; + +export function show(id) { + if (id === currentID) return; + currentID = id; + inc = null; + events = []; + error = null; + if (id == null) { + renderPlaceholder(); + return; + } + render(); + refresh(); +} + +export async function refresh() { + const id = currentID; + if (id == null) return; + try { + const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]); + if (id !== currentID) return; + inc = i; + events = t; + error = null; + } catch (err) { + if (id !== currentID) return; + error = err.status === 404 ? 'This incident does not exist.' : err.message; + } + render(); +} + +function renderPlaceholder() { + clear(pane(), h('div', { class: 'detail-placeholder' }, + h('div', {}, icon('flag', 'icon'), h('p', { text: 'Select an incident to see its alerts and timeline.' })))); +} + +function render() { + const head = h('div', { class: 'detail-head' }, + h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back }, + icon('back')), + h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }), + ); + + if (!inc) { + clear(pane(), h('div', { class: 'detail' }, head, + error ? h('div', { class: 'load-error', text: error }) : spinner())); + return; + } + + // Keep the scroll position across the periodic re-render. + const scroller = document.querySelector('.pane-detail'); + const top = scroller ? scroller.scrollTop : 0; + + clear(pane(), + h('article', { class: 'detail' }, + head, + error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), + h('h1', { class: 'detail-title', text: inc.title }), + h('div', { class: 'detail-badges' }, statusBadges()), + facts(), + groupLabels(), + alertsSection(), + timelineSection(), + ), + actionBar(), + ); + if (scroller) scroller.scrollTop = top; +} + +function statusBadges() { + const out = []; + if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`)); + out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`)); + if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { + out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed')); + } + if (inc.archived_at) out.push(badge('Archived', 'plain')); + return out; +} + +function who(id, name) { + if (id != null && id === myID()) return 'you'; + return name || 'someone'; +} + +function facts() { + const rows = []; + const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v)); + add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` })); + if (inc.acknowledged_at) { + add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`); + } + add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned'); + if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { + add('Snoozed until', when(inc.snoozed_until)); + } + if (inc.resolved_at) { + const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing'; + add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` })); + } + if (inc.archived_at) add('Archived', when(inc.archived_at)); + return h('div', { class: 'card' }, h('dl', { class: 'facts' }, rows)); +} + +function groupLabels() { + const entries = Object.entries(inc.group_labels || {}); + if (!entries.length) return null; + return h('section', { class: 'section' }, + h('h2', { class: 'section-title', text: 'Grouped by' }), + h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))), + ); +} + +function alertsSection() { + const list = inc.alerts || []; + const firing = list.filter((a) => a.status === 'firing').length; + return h('section', { class: 'section' }, + h('h2', { class: 'section-title' }, + h('span', { text: `Alerts (${list.length})` }), + firing ? h('span', { text: `${firing} firing` }) : null), + list.length + ? h('div', { class: 'card' }, list.map(alertItem)) + : h('div', { class: 'card card-pad', text: 'No alerts attached.' }), + ); +} + +function alertItem(a) { + const summary = (a.annotations && (a.annotations.summary || a.annotations.description)) || ''; + const labels = Object.entries(a.labels || {}); + return h('div', { class: 'alert-item' }, + h('div', { class: 'alert-item-head' }, + h('span', { class: 'alert-item-name', text: a.name }), + badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)), + summary && h('div', { class: 'alert-item-summary', text: summary }), + h('div', { class: 'alert-item-foot' }, + h('span', { text: `Started ${ago(a.starts_at)}` }), + h('span', { text: `Last seen ${ago(a.received_at)}` }), + a.generator_url && h('a', { href: a.generator_url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗'), + ), + labels.length > 0 && h('details', {}, + h('summary', { text: `${labels.length} labels` }), + h('div', { class: 'labels-wrap' }, labels.map(([k, v]) => labelChip(k, v)))), + ); +} + +// ---------- timeline ---------- + +function eventText(ev) { + const person = ev.user_id != null ? who(ev.user_id, ev.username) : null; + const strong = (t) => h('span', { class: 'who', text: t || 'someone' }); + const alertName = () => { + const a = (inc.alerts || []).find((x) => x.id === ev.alert_id); + return a ? a.name : 'an alert'; + }; + switch (ev.type) { + case 'triggered': return ['Incident triggered']; + case 'alert_added': return [`Alert added: ${alertName()}`]; + case 'alert_resolved': return [`Alert resolved: ${alertName()}`]; + case 'acknowledged': return [strong(person), ' acknowledged']; + case 'unacknowledged': return [strong(person), ' cleared the acknowledgement']; + case 'assigned': return ['Assigned to ', strong(person)]; + case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`]; + case 'unsnoozed': return [strong(person), ' ended the snooze']; + case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing']; + case 'note': return [strong(person), ' added a note']; + case 'notified': { + const to = person ? strong(person) : 'the fallback topic'; + if (ev.detail === 'reminder') return ['Reminder sent to ', to]; + if (ev.detail === 'resolved') return ['Resolution sent to ', to]; + return ['Paged ', to]; + } + case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : '']; + case 'deadman_silent': return ['Heartbeat went silent', ev.detail ? ` (${ev.detail})` : '']; + default: return [ev.type, ev.detail ? `: ${ev.detail}` : '']; + } +} + +function timelineSection() { + const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id); + return h('section', { class: 'section' }, + h('h2', { class: 'section-title' }, + h('span', { text: 'Timeline' }), + h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: addNote }, + icon('note'), 'Add note')), + h('div', { class: 'card' }, + sorted.length + ? h('ol', { class: 'timeline' }, sorted.map(timelineItem)) + : emptyState('No events yet', '')), + ); +} + +function timelineItem(ev) { + const mine = ev.type === 'note' && ev.user_id === myID(); + return h('li', { class: `tl-item tl-${ev.type}` }, + h('span', { class: 'tl-dot' }), + h('div', { class: 'tl-body' }, + h('div', { class: 'tl-text' }, eventText(ev)), + h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }), + ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }), + mine && h('div', { class: 'note-actions' }, + h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')), + ), + ); +} + +// ---------- actions ---------- + +const isOpen = () => inc.status !== 'resolved'; +const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until); + +function actionBar() { + let primary; + let secondary; + if (inc.status === 'triggered') { + primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge'); + } else if (inc.status === 'acknowledged') { + primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve'); + } else { + primary = inc.archived_at + ? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive') + : h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive'); + } + if (isOpen()) { + secondary = isSnoozed() + ? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze') + : h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze'); + } else { + secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note'); + } + const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more')); + const bar = h('div', { class: 'actionbar' }, primary, secondary, more); + if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true; + return bar; +} + +// run performs one action, then reloads the incident and the queue. +async function run(fn, done) { + if (busy) return; + busy = true; + render(); + try { + await fn(); + if (done) toast(done); + } catch (err) { + toast(err.message, 'error'); + } finally { + busy = false; + await refresh(); + poll.now(); + } +} + +function acknowledge() { + const id = inc.id; + return run(() => api.acknowledge(id), 'Acknowledged'); +} + +function unacknowledge() { + const id = inc.id; + return run(() => api.unacknowledge(id), 'Acknowledgement cleared'); +} + +async function resolve() { + const id = inc.id; + const ok = await confirm({ + title: 'Resolve this incident?', + text: 'Resolving is final. If these alerts fire again they open a new incident, ' + + 'and if any are still firing this one stays closed regardless. ' + + 'Use snooze if you only need it out of the way.', + confirmLabel: 'Resolve', + danger: true, + }); + if (ok) await run(() => api.resolve(id), 'Resolved'); +} + +function archive() { + const id = inc.id; + return run(() => api.archive(id), 'Archived'); +} + +function unarchive() { + const id = inc.id; + return run(() => api.unarchive(id), 'Unarchived'); +} + +function unsnooze() { + const id = inc.id; + return run(() => api.unsnooze(id), 'Snooze ended'); +} + +async function snooze() { + const id = inc.id; + const tomorrow9 = new Date(); + tomorrow9.setDate(tomorrow9.getDate() + 1); + tomorrow9.setHours(9, 0, 0, 0); + + const options = [ + ['30 minutes', { duration: '30m' }], + ['1 hour', { duration: '1h' }], + ['2 hours', { duration: '2h' }], + ['4 hours', { duration: '4h' }], + ['8 hours', { duration: '8h' }], + ['Until 09:00 tomorrow', { until: tomorrow9.toISOString() }], + ]; + const spec = await openSheet(() => [ + h('h2', { class: 'sheet-title', text: 'Snooze' }), + h('p', { class: 'sheet-text', text: 'Hide it from the queue for a while. It comes back on its own.' }), + h('ul', { class: 'menu' }, options.map(([label, value]) => + h('li', {}, h('button', { class: 'menu-item', type: 'button', onclick: () => closeSheet(value) }, + icon('clock'), label)))), + ]); + if (spec) await run(() => api.snooze(id, spec), 'Snoozed'); +} + +async function assign() { + const id = inc.id; + let list; + let onCall; + try { + [list, onCall] = await Promise.all([users(), api.onCallNow()]); + } catch (err) { + toast(err.message, 'error'); + return; + } + const me = myID(); + const sorted = [...list].sort((a, b) => (b.id === me) - (a.id === me) || a.username.localeCompare(b.username)); + const userID = await openSheet(() => [ + h('h2', { class: 'sheet-title', text: 'Assign to' }), + h('ul', { class: 'menu', role: 'menu' }, sorted.map((u) => + h('li', {}, h('button', { + class: 'menu-item', + type: 'button', + role: 'menuitemradio', + 'aria-checked': String(u.id === inc.assigned_to_id), + onclick: () => closeSheet(u.id), + }, + icon('user'), + u.id === me ? `${u.username} (you)` : u.username, + onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null, + )))), + ]); + if (userID != null) await run(() => api.assign(id, userID), 'Assigned'); +} + +async function addNote() { + const id = inc.id; + const content = await openSheet(() => { + const textarea = h('textarea', { + name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000', + }); + const form = h('form', { + class: 'sheet-form', + onsubmit: (e) => { + e.preventDefault(); + const v = textarea.value.trim(); + if (v) closeSheet(v); + }, + }, + h('h2', { class: 'sheet-title', text: 'Add note' }), + textarea, + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }), + h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })), + ); + // Ctrl/Cmd+Enter saves, as in most note fields. + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit(); + }); + return form; + }); + if (content) await run(() => api.addNote(id, content), 'Note added'); +} + +async function deleteNote(ev) { + const id = inc.id; + const ok = await confirm({ title: 'Delete this note?', text: ev.detail || '', confirmLabel: 'Delete', danger: true }); + if (ok) await run(() => api.deleteNote(id, ev.id), 'Note deleted'); +} + +async function moreMenu() { + const item = (iconName, label, fn, cls = '') => + h('li', {}, h('button', { class: `menu-item ${cls}`, type: 'button', onclick: () => closeSheet(fn) }, icon(iconName), label)); + + const items = []; + if (isOpen()) { + if (inc.status === 'triggered') items.push(item('check', 'Acknowledge', acknowledge)); + else items.push(item('undo', 'Clear acknowledgement', unacknowledge)); + items.push(item('user', 'Assign…', assign)); + items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze)); + items.push(item('note', 'Add note…', addNote)); + items.push(h('li', { class: 'menu-sep', role: 'separator' })); + items.push(item('checkCircle', 'Resolve…', resolve, 'danger')); + } else { + items.push(item('note', 'Add note…', addNote)); + items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive)); + } + + const fn = await openSheet(() => [ + h('h2', { class: 'sheet-title', text: inc.title }), + h('ul', { class: 'menu' }, items), + ]); + if (fn) await fn(); +} + +// key handles the detail's shortcuts. Returns true when it used the key. +export function key(e) { + if (!inc) { + if (e.key === 'Escape') { + back(); + return true; + } + return false; + } + switch (e.key) { + case 'Escape': back(); return true; + case 'a': if (inc.status === 'triggered') acknowledge(); return true; + case 'A': if (inc.status === 'acknowledged') unacknowledge(); return true; + case 'R': if (isOpen()) resolve(); return true; + case 's': if (isOpen()) assign(); return true; + case 'z': if (isOpen() && !isSnoozed()) snooze(); return true; + case 'Z': if (isSnoozed()) unsnooze(); return true; + case 'c': addNote(); return true; + case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true; + default: return false; + } +} diff --git a/internal/web/static/js/oncall.js b/internal/web/static/js/oncall.js new file mode 100644 index 0000000..dfd22a0 --- /dev/null +++ b/internal/web/static/js/oncall.js @@ -0,0 +1,141 @@ +// On-call: who is on duty now, the week around it, and your own next shifts. +// Read-only for now; the TUI edits the schedule. + +import * as api from './api.js'; +import { h, clear, icon, spinner } from './ui.js'; +import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js'; +import { myID } from './state.js'; + +const view = () => document.getElementById('view-oncall'); + +let weekStart = mondayOf(new Date()); +let data = null; +let error = null; + +const dayName = new Intl.DateTimeFormat(undefined, { weekday: 'short' }); +const dayDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }); + +export function show() { + if (!data) clear(view(), spinner()); + refresh(); +} + +export async function refresh() { + const start = weekStart; + const today = new Date(); + try { + const [now, week, upcoming] = await Promise.all([ + api.onCallNow(), + api.schedule(isoDate(start), isoDate(addDays(start, 6))), + api.schedule(isoDate(today), isoDate(addDays(today, 60))), + ]); + if (start !== weekStart) return; + data = { now, week, upcoming }; + error = null; + } catch (err) { + error = err.message; + } + render(); +} + +function shiftWeek(n) { + weekStart = addDays(weekStart, 7 * n); + refresh(); +} + +function render() { + 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}` }), + nowCard(), + weekCard(), + myShifts(), + ); +} + +function you(userID) { + return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null; +} + +function nowCard() { + const n = data.now; + return h('div', { class: 'card now-card' }, + h('div', { class: `avatar ${n ? '' : 'none'}`, text: n ? initial(n.username) : '–' }), + h('div', {}, + h('div', { class: 'now-label', text: 'On call now' }), + h('div', { class: 'now-name' }, n ? n.username : 'Nobody', n && you(n.user_id)), + ), + ); +} + +function weekCard() { + const byDate = new Map(data.week.map((e) => [e.date, e])); + const today = isoDate(new Date()); + const days = []; + for (let i = 0; i < 7; i++) { + const d = addDays(weekStart, i); + const key = isoDate(d); + const e = byDate.get(key); + days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` }, + h('span', { class: 'day-name', text: dayName.format(d) }), + h('span', { class: 'day-date', text: dayDate.format(d) }), + h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)), + )); + } + const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date())); + return [ + h('div', { class: 'page-head' }, + h('h2', { text: thisWeek ? 'This week' : 'Week' }), + h('div', { class: 'week-nav' }, + h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) }, + icon('chevronLeft')), + h('button', { + class: 'btn btn-ghost label', + type: 'button', + title: 'Back to this week', + onclick: () => { weekStart = mondayOf(new Date()); refresh(); }, + text: `Week ${isoWeek(weekStart)}`, + }), + h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) }, + icon('chevronRight')), + ), + ), + h('ul', { class: 'card days' }, days), + ]; +} + +// myShifts groups your upcoming dates into runs of consecutive days. +function myShifts() { + const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort(); + const runs = []; + for (const date of mine) { + const last = runs[runs.length - 1]; + if (last && isoDate(addDays(parse(last.to), 1)) === date) last.to = date; + else runs.push({ from: date, to: date }); + } + const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`; + return [ + h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })), + h('div', { class: 'card' }, + runs.length + ? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) => + h('li', {}, + h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }), + h('span', { class: 'muted', text: days(r) })), + )) + : h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })), + ]; +} + +function parse(s) { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, m - 1, d); +} + +function days(r) { + const n = Math.round((parse(r.to) - parse(r.from)) / 86400000) + 1; + return n === 1 ? '1 day' : `${n} days`; +} diff --git a/internal/web/static/js/poll.js b/internal/web/static/js/poll.js new file mode 100644 index 0000000..7d4e8b2 --- /dev/null +++ b/internal/web/static/js/poll.js @@ -0,0 +1,56 @@ +// Keeps the page fresh the way the TUI does: refresh on an interval, but only +// while the page is visible, and immediately when it becomes visible again — +// which is the moment a phone is picked up after a page. + +const INTERVAL = 20 * 1000; + +let refreshFn = null; +let timer = 0; +let running = false; +let inFlight = null; + +export function start(fn) { + refreshFn = fn; + running = true; + schedule(); +} + +export function stop() { + running = false; + clearInterval(timer); +} + +// now refreshes straight away and restarts the interval, after an action. +export function now() { + if (!running) return Promise.resolve(); + schedule(); + return tick(); +} + +function tick() { + if (!refreshFn) return Promise.resolve(); + // Collapse overlapping refreshes into the one already under way. + if (!inFlight) { + inFlight = Promise.resolve() + .then(refreshFn) + .catch(() => {}) + .finally(() => { + inFlight = null; + }); + } + return inFlight; +} + +function schedule() { + clearInterval(timer); + if (running && !document.hidden) timer = setInterval(tick, INTERVAL); +} + +document.addEventListener('visibilitychange', () => { + if (!running) return; + if (!document.hidden) tick(); + schedule(); +}); +window.addEventListener('online', () => { + if (running) tick(); +}); diff --git a/internal/web/static/js/queue.js b/internal/web/static/js/queue.js new file mode 100644 index 0000000..3e82619 --- /dev/null +++ b/internal/web/static/js/queue.js @@ -0,0 +1,182 @@ +// The incident queue: filter chips and a list of incident rows. + +import * as api from './api.js'; +import { h, clear, badge, emptyState, spinner } from './ui.js'; +import { age, until, isFuture, severityClass, labelSummary } from './format.js'; +import { state, myID } from './state.js'; +import { navigate } from './app.js'; + +// The same filters as the TUI's `f` cycle, plus archived ones to get back to. +const FILTERS = [ + { id: 'open', label: 'Open', query: { sort: 'severity' } }, + { id: 'triggered', label: 'Triggered', query: { status: 'triggered', sort: 'severity' } }, + { id: 'acknowledged', label: 'Acked', query: { status: 'acknowledged', sort: 'severity' } }, + { id: 'snoozed', label: 'Snoozed', query: { snoozed: 'true' } }, + { id: 'resolved', label: 'Resolved', query: { status: 'resolved' } }, + { id: 'archived', label: 'Archived', query: { status: 'resolved', archived: 'true' } }, +]; + +const EMPTY = { + open: ['All clear', 'Nothing open right now.'], + triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'], + acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'], + snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'], + resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'], + archived: ['Nothing archived', ''], +}; + +let filter = loadFilter(); +let items = null; // null while loading +let error = null; +let selected = null; +let cursor = -1; // keyboard position in the list +let built = false; + +function loadFilter() { + try { + const f = sessionStorage.getItem('terdut.queue.filter'); + if (FILTERS.some((x) => x.id === f)) return f; + } catch { + /* storage unavailable */ + } + return 'open'; +} + +function saveFilter() { + try { + sessionStorage.setItem('terdut.queue.filter', filter); + } catch { + /* storage unavailable */ + } +} + +export function show(incidentID) { + selected = incidentID; + if (!built) { + renderChips(); + built = true; + } + renderList(); +} + +export async function refresh({ fresh = false } = {}) { + const f = FILTERS.find((x) => x.id === filter); + const requested = filter; + try { + // The open list is already fetched for the badges; no need to ask twice. + const result = filter === 'open' && !fresh ? state.open : await api.incidents(f.query); + if (requested !== filter) return; + items = result; + error = null; + } catch (err) { + if (requested !== filter) return; + error = err.message; + } + renderList(); +} + +function setFilter(id) { + if (id === filter) return; + filter = id; + saveFilter(); + items = null; + cursor = -1; + renderChips(); + renderList(); + refresh({ fresh: true }); +} + +function renderChips() { + const el = document.getElementById('queue-filters'); + clear(el, FILTERS.map((f) => + h('button', { + class: 'chip', + type: 'button', + role: 'tab', + 'aria-selected': String(f.id === filter), + onclick: () => setFilter(f.id), + text: f.label, + }), + )); +} + +function renderList() { + const el = document.getElementById('queue-list'); + if (error && !items) { + clear(el, h('div', { class: 'load-error', text: error })); + return; + } + if (!items) { + clear(el, spinner()); + return; + } + if (!items.length) { + const [title, text] = EMPTY[filter]; + clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null)); + return; + } + clear(el, + error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), + items.map((inc, i) => row(inc, i)), + ); +} + +function row(inc, index) { + const snoozed = isFuture(inc.snoozed_until); + const resolved = inc.status === 'resolved'; + + let status; + if (resolved) status = badge('Resolved', 'st-resolved'); + else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed'); + else if (inc.status === 'acknowledged') { + const by = inc.acknowledged_by_id === myID() ? 'you' : inc.acknowledged_by; + status = badge(`Acked${by ? ' · ' + by : ''}`, 'st-acknowledged'); + } + else status = badge('Triggered', 'st-triggered'); + + let assignee = null; + if (inc.assigned_to_id != null) { + assignee = h('span', { text: inc.assigned_to_id === myID() ? '→ you' : `→ ${inc.assigned_to}` }); + } + + // The server already puts the group labels in the title; show only the rest. + const labels = labelSummary(Object.fromEntries( + Object.entries(inc.group_labels || {}).filter(([k, v]) => !inc.title.includes(`${k}=${v}`)))); + return h('a', { + class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`, + href: `/incidents/${inc.id}`, + 'aria-current': inc.id === selected ? 'true' : null, + dataset: { index: String(index) }, + }, + h('div', { class: 'row-title', text: inc.title }), + h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }), + h('div', { class: 'row-meta' }, + status, + assignee, + labels && h('span', { class: 'labels', text: labels }), + ), + ); +} + +// key handles j/k/enter on the list. Returns true when it used the key. +export function key(e) { + if (!items || !items.length) return false; + if (e.key === 'j' || e.key === 'ArrowDown') { + cursor = Math.min(items.length - 1, cursor + 1); + } else if (e.key === 'k' || e.key === 'ArrowUp') { + cursor = Math.max(0, cursor - 1); + } else if (e.key === 'Enter' && cursor >= 0) { + navigate(`/incidents/${items[cursor].id}`); + return true; + } else if (e.key === 'f') { + const i = FILTERS.findIndex((x) => x.id === filter); + setFilter(FILTERS[(i + 1) % FILTERS.length].id); + return true; + } else { + return false; + } + renderList(); + const el = document.querySelector(`#queue-list [data-index="${cursor}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); + return true; +} diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js new file mode 100644 index 0000000..9a05cc1 --- /dev/null +++ b/internal/web/static/js/state.js @@ -0,0 +1,31 @@ +// State shared between views: who is signed in, the user list, and the open +// queue that drives the badges. + +import * as api from './api.js'; + +export const state = { + me: null, // { user, has_password } + open: [], // the default queue: open, not snoozed +}; + +export function myID() { + return state.me ? state.me.user.id : null; +} + +// The user list changes rarely; it is fetched once and then at most every +// five minutes, for the assign sheet and for display names. +let usersCache = null; +let usersAt = 0; +export async function users() { + if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) { + usersCache = await api.users(); + usersAt = Date.now(); + } + return usersCache; +} + +export function reset() { + state.me = null; + state.open = []; + usersCache = null; +} diff --git a/internal/web/static/js/ui.js b/internal/web/static/js/ui.js new file mode 100644 index 0000000..e9fe384 --- /dev/null +++ b/internal/web/static/js/ui.js @@ -0,0 +1,173 @@ +// DOM helpers, the bottom sheet, confirmation and toasts. + +// h builds an element. attrs: class, text, on, dataset, aria/other +// attributes; boolean true sets an empty attribute, false/null skips it. +export function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k === 'text') el.textContent = v; + else if (k === 'dataset') Object.assign(el.dataset, v); + else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v); + else if (k in el && typeof v !== 'string') el[k] = v; + else el.setAttribute(k, v === true ? '' : v); + } + append(el, children); + return el; +} + +function append(el, children) { + for (const c of children.flat(Infinity)) { + if (c == null || c === false) continue; + el.append(c instanceof Node ? c : document.createTextNode(String(c))); + } +} + +export function clear(el, ...children) { + el.replaceChildren(); + append(el, children); + return el; +} + +// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything. +const ICONS = { + back: ['M15 18l-6-6 6-6'], + more: ['M5 12h.01M12 12h.01M19 12h.01'], + check: ['M5 12.5l4.5 4.5L19 7'], + checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'], + undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'], + user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'], + clock: ['circle:12,12,9', 'M12 7v5l3 2'], + bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'], + note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'], + archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'], + flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'], + trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], + chevronLeft: ['M15 18l-6-6 6-6'], + chevronRight: ['M9 6l6 6-6 6'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'], + logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'], +}; + +const SVG = 'http://www.w3.org/2000/svg'; +export function icon(name, cls = 'icon') { + const svg = document.createElementNS(SVG, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('class', cls); + for (const d of ICONS[name] || []) { + let node; + if (d.startsWith('circle:')) { + const [cx, cy, r] = d.slice(7).split(','); + node = document.createElementNS(SVG, 'circle'); + node.setAttribute('cx', cx); + node.setAttribute('cy', cy); + node.setAttribute('r', r); + } else { + node = document.createElementNS(SVG, 'path'); + node.setAttribute('d', d); + } + svg.append(node); + } + return svg; +} + +// ---------- sheet ---------- + +const sheet = () => document.getElementById('sheet'); +let sheetResolve = null; + +// openSheet shows content in the bottom sheet (a centred dialog on desktop) +// and resolves with whatever closeSheet is given, or null when dismissed. +export function openSheet(build) { + const dlg = sheet(); + if (dlg.open) closeSheet(null); + const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' })); + append(inner, [build()]); + clear(dlg, inner); + dlg.showModal(); + const first = dlg.querySelector('[autofocus]'); + if (first) first.focus(); + return new Promise((resolve) => { + sheetResolve = resolve; + }); +} + +export function closeSheet(value = null) { + const dlg = sheet(); + const resolve = sheetResolve; + sheetResolve = null; + if (dlg.open) dlg.close(); + if (resolve) resolve(value); +} + +export function sheetIsOpen() { + return sheet().open; +} + +export function initSheet() { + const dlg = sheet(); + // A tap on the backdrop lands on the dialog element itself. + dlg.addEventListener('click', (e) => { + if (e.target === dlg) closeSheet(null); + }); + dlg.addEventListener('cancel', (e) => { + e.preventDefault(); + closeSheet(null); + }); +} + +// confirm asks a yes/no question in the sheet. +export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) { + return openSheet(() => [ + h('h2', { class: 'sheet-title', text: title }), + text && h('p', { class: 'sheet-text', text }), + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }), + h('button', { + class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`, + type: 'button', + autofocus: true, + onclick: () => closeSheet(true), + text: confirmLabel, + }), + ), + ]).then((v) => v === true); +} + +// ---------- toast ---------- + +let toastTimer = 0; +export function toast(message, kind = '') { + const el = document.getElementById('toast'); + el.textContent = message; + el.className = `toast ${kind}`; + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.hidden = true; + }, kind === 'error' ? 5000 : 2500); +} + +// ---------- misc ---------- + +export function badge(text, cls = '') { + return h('span', { class: `badge ${cls}`, text }); +} + +export function labelChip(k, v) { + return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v })); +} + +export function emptyState(title, text, iconName) { + return h('div', { class: 'empty' }, + iconName && icon(iconName), + h('strong', { text: title }), + text && h('span', { text }), + ); +} + +export function spinner() { + return h('div', { class: 'empty' }, h('span', { class: 'spinner' })); +} diff --git a/internal/web/static/manifest.webmanifest b/internal/web/static/manifest.webmanifest new file mode 100644 index 0000000..e74c1e9 --- /dev/null +++ b/internal/web/static/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "terdut", + "short_name": "terdut", + "description": "Incident queue and on-call for terdut-server", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0f1115", + "theme_color": "#1b1e25", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..6c50424 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,135 @@ +// Package web serves the web UI, compiled into the binary. +// +// There is no build step: the files under static/ are what the browser gets. +// The page talks to the server's own /api over the same origin, signed in with +// the session cookie from POST /api/login. +package web + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/base64" + "errors" + "io/fs" + "net/http" + "path" + "strings" + "time" +) + +//go:embed static +var files embed.FS + +// asset is one embedded file, with its validator computed once at startup. +type asset struct { + body []byte + etag string + ctype string + cache string +} + +// Handler serves the embedded site. A path without a file extension that +// matches no file gets index.html, so a deep link such as /incidents/42 — the +// target of a notification tap — survives a reload; the page reads the path +// and renders the right view. A missing file with an extension is a real 404. +func Handler() (http.Handler, error) { + root, err := fs.Sub(files, "static") + if err != nil { + return nil, err + } + + assets := make(map[string]*asset) + err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := fs.ReadFile(root, p) + if err != nil { + return err + } + sum := sha256.Sum256(b) + assets["/"+p] = &asset{ + body: b, + etag: `"` + base64.RawURLEncoding.EncodeToString(sum[:16]) + `"`, + ctype: contentType(p), + cache: cacheControl(p), + } + return nil + }) + if err != nil { + return nil, err + } + + index, ok := assets["/index.html"] + if !ok { + return nil, errors.New("web: static/index.html is missing") + } + + // embed.FS reports a zero ModTime, so http.FileServerFS would emit no + // validator and every asset would be refetched in full on every load. + // Hence the ETag above and ServeContent below, with a zero time that + // suppresses Last-Modified. + var noTime time.Time + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + p := path.Clean(r.URL.Path) + f, ok := assets[p] + if !ok { + if path.Ext(p) != "" { + http.NotFound(w, r) + return + } + f = index + } + + w.Header().Set("Content-Type", f.ctype) + w.Header().Set("Cache-Control", f.cache) + w.Header().Set("ETag", f.etag) + // The page loads nothing from anywhere else, so the policy can say so + // outright rather than carve out exceptions. + w.Header().Set("Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "+ + "connect-src 'self'; manifest-src 'self'; form-action 'self'; "+ + "frame-ancestors 'none'; base-uri 'none'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + + http.ServeContent(w, r, "", noTime, bytes.NewReader(f.body)) + }), nil +} + +func contentType(p string) string { + switch path.Ext(p) { + case ".html": + return "text/html; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".js": + return "text/javascript; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".png": + return "image/png" + case ".webmanifest": + return "application/manifest+json" + default: + return "application/octet-stream" + } +} + +// cacheControl keeps index.html revalidating on every load, because it names +// the current asset paths. Assets carry an ETag, so a five-minute window costs +// one conditional request after a deploy rather than a stale page. +func cacheControl(p string) string { + if strings.HasSuffix(p, ".html") { + return "no-cache" + } + return "public, max-age=300" +}