Compare commits

...

5 Commits

Author SHA1 Message Date
Niklas Ye d7f4bf3765 Set the chart's placeholder version to 0.51.0
CI / chart (push) Successful in 5s
CI / security (push) Successful in 36s
CI / test (push) Successful in 6m49s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m17s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.51.0 does not say 0.50.0, as in
the 0.50.0 placeholder commit and 525947e.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 19:00:24 +01:00
Niklas Ye 6e95c86521 Draw boolean alert metrics as an on/off timeline
A metric that only ever reads 0 or 1 (up == 0, anything with bool) gave the
line chart nothing to show: a flat line or a square wave on a y axis padded
to -1..2. When every drawn series is 0/1 the incident page now draws one row
per series instead, filled where the value was 1, a thin mark where it was
0, and the bare track where Prometheus returned no samples, which for these
expressions means the condition did not hold.

The decision is per chart, so one chart never mixes styles; any other value
keeps the line chart as before. Frontend only: the endpoint already returns
the points and step_seconds, which pick() now passes on so the last sample
gets a width and a gap in the samples shows as a gap.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 19:00:24 +01:00
Niklas Ye cd3b2c2bea Clamp the avatar scaler's channels before narrowing them
CI / chart (push) Successful in 2s
CI / security (push) Successful in 33s
CI / test (push) Successful in 6m36s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m31s
Release / scan-image (push) Successful in 32s
Release / binaries (push) Successful in 3m16s
gosec's G115 failed the security job on v0.50.0's first push: the box
filter narrows uint64 channel sums to uint8. They cannot overflow, since a
premultiplied channel never exceeds its alpha, but the conversion now goes
through to8, which clamps to 16 bits first so that holds by construction
rather than by argument.

No behaviour change, and nothing else in the avatar work moves.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:46:35 +01:00
Niklas Ye 525947e809 Set the chart's placeholder version to 0.50.0
CI / chart (push) Successful in 2s
CI / security (push) Failing after 3m33s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.50.0 does not say 0.49.1, as in
the 0.49.1 placeholder commit and 12535e0.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:42:05 +01:00
Niklas Ye 536dbcaebe Let users upload a profile picture, cropped in the browser
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.

The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.

GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:42:05 +01:00
19 changed files with 719 additions and 31 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.49.1
appVersion: "v0.49.1"
version: 0.51.0
appVersion: "v0.51.0"
+4
View File
@@ -51,6 +51,10 @@ data" for the current value means the condition no longer holds**, not that Prom
A rule expression matches every series, not just the one that fired; only series that agree with the
alert's labels are kept, at most five per alert.
When every series only ever reads `0` or `1` (a boolean such as `up == 0`), a line is not
informative, so the UI draws an on/off timeline instead: one row per series, filled where the value
was `1`, a thin mark where it was `0`, and the bare track where there were no samples.
## Endpoint
`GET /api/incidents/{id}/metrics` — same team scoping as the other incident routes.
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"bytes"
"database/sql"
"errors"
"image"
"image/color"
"image/draw"
_ "image/jpeg" // registers the JPEG decoder with image.Decode
"image/png"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
// avatarSize is the side of the stored square picture. The web UI crops to
// it before uploading; the server enforces it anyway.
avatarSize = 256
// maxAvatarBytes caps the upload. The web UI sends a ~256x256 PNG, a few
// tens of KiB; this leaves room for a client that sends a photo as is.
maxAvatarBytes = 1 << 20
// maxAvatarPixels bounds the decoded size, checked from the header before
// decoding: a small file can declare an enormous canvas.
maxAvatarPixels = 16 << 20
)
// normalizeAvatar decodes a PNG or JPEG, crops it to its centred square, scales
// it to avatarSize and encodes it as PNG. Decoding and re-encoding is what
// makes the stored bytes trustworthy: EXIF, trailing data and anything that is
// not pixels do not survive it.
func normalizeAvatar(raw []byte) ([]byte, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxAvatarPixels {
return nil, errors.New("the picture is too large")
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
b := img.Bounds()
side := min(b.Dx(), b.Dy())
src := image.Rect(
b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2,
b.Min.X+(b.Dx()-side)/2+side, b.Min.Y+(b.Dy()-side)/2+side)
var out *image.NRGBA
if side <= avatarSize {
out = image.NewNRGBA(image.Rect(0, 0, side, side))
draw.Draw(out, out.Bounds(), img, src.Min, draw.Src)
} else {
out = boxScale(img, src, avatarSize)
}
var buf bytes.Buffer
if err := png.Encode(&buf, out); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// boxScale shrinks the square region src of img to size x size by averaging the
// source pixels each output pixel covers. Plain area averaging is enough for a
// downscale and keeps the standard library the only dependency.
func boxScale(img image.Image, src image.Rectangle, size int) *image.NRGBA {
out := image.NewNRGBA(image.Rect(0, 0, size, size))
n := src.Dx()
for y := 0; y < size; y++ {
y0, y1 := src.Min.Y+y*n/size, src.Min.Y+(y+1)*n/size
for x := 0; x < size; x++ {
x0, x1 := src.Min.X+x*n/size, src.Min.X+(x+1)*n/size
var r, g, b, a, cnt uint64
for sy := y0; sy < y1; sy++ {
for sx := x0; sx < x1; sx++ {
pr, pg, pb, pa := img.At(sx, sy).RGBA()
r, g, b, a, cnt = r+uint64(pr), g+uint64(pg), b+uint64(pb), a+uint64(pa), cnt+1
}
}
if cnt == 0 {
continue
}
// RGBA() is alpha-premultiplied 16-bit; undo that for NRGBA.
a /= cnt
if a == 0 {
continue
}
r, g, b = r/cnt*0xffff/a, g/cnt*0xffff/a, b/cnt*0xffff/a
out.SetNRGBA(x, y, color.NRGBA{to8(r), to8(g), to8(b), to8(a)})
}
}
return out
}
// to8 narrows a 16-bit channel to 8 bits. The clamp is what makes the
// conversion safe by construction: a premultiplied channel never exceeds its
// alpha, so it is not expected to bite, but nothing here relies on that.
func to8(v uint64) uint8 {
return uint8(min(v, 0xffff) >> 8)
}
func handleGetAvatar(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 data []byte
var version int64
err = db.QueryRowContext(r.Context(),
"SELECT data, updated_at FROM user_avatars WHERE user_id = $1", id).Scan(&data, &version)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("no avatar"))
return
}
if err != nil {
serverError(w, r, err)
return
}
etag := `"` + strconv.FormatInt(version, 10) + `"`
w.Header().Set("ETag", etag)
// private: it is behind a login. The URL carries ?v=, so the browser can
// keep it for a long time without ever showing a stale picture.
w.Header().Set("Cache-Control", "private, max-age=86400")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(data)
}
}
// handleSetAvatar stores the request body, a PNG or JPEG, as the user's picture.
func handleSetAvatar(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
}
if !requireSelfOrAdmin(w, r, id) {
return
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAvatarBytes))
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
respond(w, http.StatusRequestEntityTooLarge, errResp("the picture is too large"))
return
}
respond(w, http.StatusBadRequest, errResp("could not read the picture"))
return
}
data, err := normalizeAvatar(raw)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
version := time.Now().UnixMilli()
res, err := db.ExecContext(r.Context(),
`INSERT INTO user_avatars (user_id, data, updated_at)
SELECT id, $2, $3 FROM users WHERE id = $1
ON CONFLICT (user_id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
id, data, version)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
respond(w, http.StatusOK, map[string]int64{"avatar_version": version})
}
}
func handleDeleteAvatar(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
}
if !requireSelfOrAdmin(w, r, id) {
return
}
if _, err := db.ExecContext(r.Context(),
"DELETE FROM user_avatars WHERE user_id = $1", id); err != nil {
serverError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+154
View File
@@ -0,0 +1,154 @@
package api_test
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"net/http"
"testing"
)
func testImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 200, A: 255})
}
}
return img
}
func pngBytes(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// putRaw sends a non-JSON body with the administrator's key.
func (s *ts) putRaw(t *testing.T, path string, body []byte) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodPut, s.URL+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+s.key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestAvatar_UploadNormalizesAndServes(t *testing.T) {
s := newTS(t)
uid, call := member(t, s, "ava")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET before upload: %d, want 404", resp.StatusCode)
}
// A non-square JPEG, bigger than the stored size: cropped and scaled to 256².
var jb bytes.Buffer
if err := jpeg.Encode(&jb, testImage(600, 400), nil); err != nil {
t.Fatal(err)
}
resp := s.putRaw(t, path, jb.Bytes()) // an administrator may set anybody's
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
var out struct {
Version int64 `json:"avatar_version"`
}
decode(t, resp, &out)
resp = call(http.MethodGet, path, nil)
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "image/png" {
t.Fatalf("GET: %d %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
t.Fatalf("served bytes are not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 256 {
t.Errorf("stored size %v, want 256x256", b)
}
var users []struct {
ID int64 `json:"id"`
AvatarVersion *int64 `json:"avatar_version"`
}
decode(t, call(http.MethodGet, "/api/users", nil), &users)
found := false
for _, u := range users {
if u.ID == uid {
found = true
if u.AvatarVersion == nil || *u.AvatarVersion != out.Version {
t.Errorf("user list avatar_version = %v, want %d", u.AvatarVersion, out.Version)
}
}
}
if !found {
t.Fatal("user not listed")
}
if resp := call(http.MethodDelete, path, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE: %d", resp.StatusCode)
}
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET after delete: %d, want 404", resp.StatusCode)
}
}
func TestAvatar_Rejections(t *testing.T) {
s := newTS(t)
_, other := member(t, s, "other")
uid, _ := member(t, s, "ava2")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := other(http.MethodPut, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member PUT: %d, want 403", resp.StatusCode)
}
if resp := other(http.MethodDelete, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member DELETE: %d, want 403", resp.StatusCode)
}
if resp := s.putRaw(t, path, []byte("not an image")); resp.StatusCode != http.StatusBadRequest {
t.Errorf("garbage: %d, want 400", resp.StatusCode)
}
if resp := s.putRaw(t, path, make([]byte, 2<<20)); resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("oversize: %d, want 413", resp.StatusCode)
}
if resp := s.putRaw(t, "/api/users/999999/avatar", pngBytes(t, testImage(32, 32))); resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown user: %d, want 404", resp.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, s.URL+path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unauthenticated GET: %d, want 401", resp.StatusCode)
}
}
func TestAvatar_RemovedWithTheUser(t *testing.T) {
s := newTS(t)
uid, _ := member(t, s, "gone")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := s.putRaw(t, path, pngBytes(t, testImage(64, 64))); resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
if resp := s.req(t, http.MethodDelete, "/api/users/"+id64(uid), nil); resp.StatusCode >= 300 {
t.Fatalf("delete user: %d", resp.StatusCode)
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM user_avatars WHERE user_id = $1", uid).Scan(&n); err != nil || n != 0 {
t.Errorf("avatar rows after user delete = %d (%v), want 0", n, err)
}
}
+5
View File
@@ -117,6 +117,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// set up" that the person holding the phone can confirm.
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
r.Put("/api/users/{id}/password", handleSetPassword(db))
// The picture itself is readable by anyone signed in (like the user
// list), changeable only by its owner or an administrator.
r.Get("/api/users/{id}/avatar", handleGetAvatar(db))
r.Put("/api/users/{id}/avatar", handleSetAvatar(db))
r.Delete("/api/users/{id}/avatar", handleDeleteAvatar(db))
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
+4 -4
View File
@@ -117,7 +117,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion); err != nil {
serverError(w, r, err)
return
}
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
if err != nil {
return u, err
}
@@ -0,0 +1,11 @@
-- A user's profile picture: one already-cropped, re-encoded 256x256 PNG per
-- user. Kept in Postgres rather than on a volume so every replica serves it and
-- the pg_dump backup covers it. updated_at is in milliseconds, not seconds like
-- the other timestamps, because it doubles as the cache-busting version of the
-- image URL and a re-upload within the same second must change it.
CREATE TABLE user_avatars (
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data bytea NOT NULL,
updated_at bigint NOT NULL
);
+5
View File
@@ -19,6 +19,11 @@ type User struct {
// every acknowledgement and timeline entry they made.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// AvatarVersion identifies the user's current profile picture, or is nil
// when they have none and clients show their initials. It changes on every
// upload, so it goes in the image URL (?v=) as a cache buster.
AvatarVersion *int64 `json:"avatar_version,omitempty"`
// IsAdmin is the system administrator flag: managing users and API keys.
// Not omitempty — a client has to be able to tell "false" from "this server
// is too old to have the field", and the web UI decides what to show from
+1
View File
@@ -14,6 +14,7 @@ func TestCopyIncidentIsEmbedded(t *testing.T) {
for file, want := range map[string]string{
"js/incident.js": "copyIncident",
"js/ui.js": "copy:",
"js/metricchart.js": "isBool",
} {
b, err := fs.ReadFile(sub, file)
if err != nil {
+16
View File
@@ -622,6 +622,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.mc-line.s4, .mc-swatch.s4 { stroke: var(--muted); background: var(--muted); }
.mc-dot.s0 { fill: var(--accent); } .mc-dot.s1 { fill: var(--warn); } .mc-dot.s2 { fill: var(--ok); }
.mc-dot.s3 { fill: var(--crit); } .mc-dot.s4 { fill: var(--muted); }
.mc-track { fill: var(--border); opacity: .45; }
.mc-off { fill: var(--faint); opacity: .6; }
.mc-on.s0 { fill: var(--accent); } .mc-on.s1 { fill: var(--warn); } .mc-on.s2 { fill: var(--ok); }
.mc-on.s3 { fill: var(--crit); } .mc-on.s4 { fill: var(--muted); }
.mc-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; }
.metrics-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; color: var(--muted); }
.metrics-legend li { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
@@ -789,6 +793,18 @@ details[open] > summary { margin-bottom: 8px; }
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
/* A picture lies over the initial, which stays underneath as the fallback. */
.avatar, .rota-chip, .nav-avatar { position: relative; overflow: hidden; }
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.avatar-sm { width: 24px; height: 24px; font-size: 12px; }
.avatar-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.crop-stage { position: relative; width: 256px; max-width: 100%; aspect-ratio: 1; margin: 0 auto 12px; overflow: hidden; border-radius: var(--radius); background: var(--surface-2); touch-action: none; }
.crop-canvas { display: block; width: 100%; height: 100%; cursor: grab; }
.crop-canvas:active { cursor: grabbing; }
/* The circle the picture will be shown in: everything outside it is dimmed. */
.crop-mask { position: absolute; inset: 0; border-radius: 50%; box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.5); pointer-events: none; }
.crop-zoom { display: grid; gap: 4px; width: 256px; max-width: 100%; margin: 0 auto 12px; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
is easier to spot there than this plain accent-coloured text. */
+129 -5
View File
@@ -1,10 +1,11 @@
// 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 { h, clear, icon, toast, openSheet, closeSheet } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.js';
import { state, forgetUsers } from './state.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
import { signOut, renderAccountNav } from './app.js';
const view = () => document.getElementById('view-more');
@@ -24,10 +25,11 @@ function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id),
h('div', {},
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'account-email', text: user.email }),
avatarControls(user))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyCard(user, providers),
@@ -299,3 +301,125 @@ function shortcuts() {
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
h('td', { text: v })))));
}
// ---------- profile picture ----------
const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1
function avatarControls(user) {
const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true });
input.addEventListener('change', async () => {
const file = input.files[0];
input.value = '';
if (!file) return;
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
toast('That file is not an image this browser can read.', 'error');
return;
}
const blob = await cropSheet(bitmap);
if (!blob) return;
try {
const { avatar_version: v } = await api.setAvatar(user.id, blob);
changed(user, v);
toast('Picture updated');
} catch (err) {
toast(err.message, 'error');
}
});
const remove = async () => {
try {
await api.removeAvatar(user.id);
changed(user, null);
toast('Picture removed');
} catch (err) {
toast(err.message, 'error');
}
};
return h('div', { class: 'avatar-actions' },
input,
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }),
user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' }));
}
// The picture changed: keep the cached account and user list in step and repaint.
function changed(user, version) {
user.avatar_version = version ?? undefined;
setAvatarVersion(user.id, version);
forgetUsers();
renderAccountNav();
show();
}
// A square stage with the image under it: drag to move, slider or wheel to
// zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or
// null if dismissed.
function cropSheet(bitmap) {
const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage
let zoom = 1;
let cx = bitmap.width / 2; // the image point shown at the stage's centre
let cy = bitmap.height / 2;
const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP });
const clamp = () => {
const half = CROP / 2 / (base * zoom);
cx = Math.min(bitmap.width - half, Math.max(half, cx));
cy = Math.min(bitmap.height - half, Math.max(half, cy));
};
const paint = (ctx = canvas.getContext('2d'), size = CROP) => {
clamp();
const s = base * zoom * (size / CROP);
ctx.clearRect(0, 0, size, size);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s);
};
const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' });
slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); });
let drag = null;
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('pointermove', (e) => {
if (!drag) return;
const k = CROP / canvas.getBoundingClientRect().width / (base * zoom);
cx -= (e.clientX - drag.x) * k;
cy -= (e.clientY - drag.y) * k;
drag = { x: e.clientX, y: e.clientY };
paint();
});
const end = () => { drag = null; };
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)));
slider.value = String(zoom);
paint();
}, { passive: false });
return openSheet(() => {
queueMicrotask(() => paint());
return [
h('h2', { class: 'sheet-title', text: 'Crop your picture' }),
h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })),
h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', {
class: 'btn btn-primary', type: 'button', text: 'Save',
onclick: () => {
const out = document.createElement('canvas');
out.width = CROP;
out.height = CROP;
paint(out.getContext('2d'));
out.toBlob((b) => closeSheet(b), 'image/png');
},
})),
];
});
}
+24
View File
@@ -72,6 +72,30 @@ export const setPassword = (userID, password, currentPassword) =>
// users
export const users = () => call('GET', '/users');
// A profile picture is raw image bytes, not JSON, so it does not go through call.
export async function setAvatar(userID, blob) {
let resp;
try {
resp = await fetch(`/api/users/${userID}/avatar`, {
method: 'PUT', headers: { 'Content-Type': blob.type || 'image/png' }, body: blob, credentials: 'same-origin',
});
} catch {
throw new ApiError(0, 'Cannot reach the server.');
}
let data = null;
try {
data = await resp.json();
} catch {
/* keep null */
}
if (!resp.ok) {
if (resp.status === 401) onUnauthorized();
throw new ApiError(resp.status, (data && data.error) || `Server answered ${resp.status}.`);
}
return data;
}
export const removeAvatar = (userID) => call('DELETE', `/users/${userID}/avatar`);
// What one person is in. /teams answers "what am I in" and cannot be asked
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
+9 -3
View File
@@ -3,7 +3,7 @@
import * as api from './api.js';
import * as ui from './ui.js';
import * as poll from './poll.js';
import { state, reset, loadTeams } from './state.js';
import { state, reset, loadTeams, users as ui_users } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
@@ -17,6 +17,7 @@ import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
const $ = (id) => document.getElementById(id);
@@ -473,10 +474,13 @@ export async function signOut() {
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
export function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').replaceChildren(initial(name));
$('nav-avatar').classList.remove('has-img');
setAvatarVersion(state.me.user.id, state.me.user.avatar_version);
withAvatar($('nav-avatar'), state.me.user.id);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
@@ -488,6 +492,8 @@ function showApp() {
$('login').hidden = true;
$('app').hidden = false;
render();
// Who has a picture: the first paint shows initials, the list repaints it.
ui_users().then(render).catch(() => {});
poll.start(refresh);
poll.now();
}
+35
View File
@@ -0,0 +1,35 @@
// Profile pictures. A user either has one (the server says so with an
// avatar_version, which is also the cache buster in the image URL) or shows
// their initial in a coloured circle, as before.
const versions = new Map(); // user id -> avatar_version
// Learn from a user list (or a single user) which people have a picture.
export function noteAvatars(list) {
for (const u of list || []) setAvatarVersion(u.id, u.avatar_version);
}
export function setAvatarVersion(userID, version) {
if (version == null) versions.delete(userID);
else versions.set(userID, version);
}
export function clearAvatars() {
versions.clear();
}
// withAvatar lays the person's picture over el, which keeps its initial
// underneath: if the image fails to load, the initial is what shows.
export function withAvatar(el, userID) {
const v = versions.get(userID);
if (v == null) return el;
el.classList.add('has-img');
const img = document.createElement('img');
img.className = 'avatar-img';
img.alt = '';
img.decoding = 'async';
img.src = `/api/users/${userID}/avatar?v=${v}`;
img.addEventListener('error', () => img.remove());
el.append(img);
return el;
}
+3 -2
View File
@@ -7,9 +7,10 @@ import {
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial,
} from './format.js';
import { myID, users } from './state.js';
import { withAvatar } from './avatar.js';
import { back } from './app.js';
import { alertMetrics } from './metricchart.js';
@@ -651,7 +652,7 @@ async function assign() {
'aria-checked': String(u.id === inc.assigned_to_id),
onclick: () => closeSheet(u.id),
},
icon('user'),
withAvatar(h('span', { class: 'avatar avatar-sm', text: initial(u.username) }), u.id),
u.id === me ? `${u.username} (you)` : u.username,
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
)))),
+96 -10
View File
@@ -74,13 +74,14 @@ function pick(entry) {
from: Math.min(live.window_start, snaps.opened ? snaps.opened.window_start : Infinity),
to: live.window_end,
current: live.current || [],
step: live.step_seconds,
isLive: true,
};
}
const snap = snaps.closed || snaps.opened;
if (snap && !snap.error) {
return {
series: merge(parseSeries(snap.series)), from: snap.window_start, to: snap.window_end, current: [], isLive: false,
series: merge(parseSeries(snap.series)), from: snap.window_start, to: snap.window_end, current: [], step: snap.step_seconds, isLive: false,
};
}
return null;
@@ -105,7 +106,7 @@ export function alertMetrics(entry, startsAtISO, resolvedISO) {
const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null;
return h('div', { class: 'metrics' },
currentLine(entry, data),
data.series.length ? chart(data, startSec, endSec) : h('div', { class: 'metrics-note', text: 'No samples in this window.' }),
data.series.length ? (isBool(data.series) ? timeline(data, startSec, endSec) : chart(data, startSec, endSec)) : h('div', { class: 'metrics-note', text: 'No samples in this window.' }),
legend(data.series),
entry.expr && h('code', { class: 'metrics-expr', text: entry.expr }),
);
@@ -130,6 +131,98 @@ function legend(series) {
h('li', {}, h('span', { class: `mc-swatch s${i}` }), h('span', { text: seriesName(s.labels) }))));
}
// addMarkers draws the dashed "Alert started" / "Incident resolved" lines.
function addMarkers(root, { t0, t1, x, top, bottom, startSec, endSec }) {
const marker = (t, cls, text) => {
if (t == null || t < t0 || t > t1) return;
const m = svg('line', { class: `mc-marker ${cls}`, x1: x(t), x2: x(t), y1: top, y2: bottom });
m.appendChild(svg('title', {}, `${text} ${when(iso(t))}`));
root.appendChild(m);
};
marker(startSec, 'start', 'Alert started');
marker(endSec, 'end', 'Incident resolved');
}
// isBool is true when every drawn series only ever reads 0 or 1; such a metric
// says when something was on, so it is drawn as a timeline, not a line.
function isBool(allSeries) {
const series = allSeries.slice(0, MAX_SERIES);
let any = false;
for (const s of series) {
for (const [, v] of s.points) {
if (v !== 0 && v !== 1) return false;
any = true;
}
}
return any;
}
// runsOf folds a series into [from, to, value] spans. A sample lasts until the
// next one, or one step when the next is missing or more than 1.5 steps away.
function runsOf(points, step, tEnd) {
const runs = [];
points.forEach(([t, v], n) => {
const next = points[n + 1];
const contiguous = next && (!step || next[0] - t <= step * 1.5);
const end = Math.min(contiguous ? next[0] : t + (step || 0), tEnd);
const last = runs[runs.length - 1];
if (last && last[2] === v && last[1] === t) last[1] = end;
else runs.push([t, end, v]);
});
return runs;
}
function timeline(data, startSec, endSec) {
const W = 480;
const padL = 40;
const padR = 8;
const padT = 8;
const padB = 18;
const rowH = 16;
const rowGap = 4;
const series = data.series.slice(0, MAX_SERIES);
const H = padT + padB + series.length * rowH + (series.length - 1) * rowGap;
let t0 = data.from; let t1 = data.to;
for (const s of series) {
for (const [t] of s.points) {
if (t < t0) t0 = t;
if (t > t1) t1 = t;
}
}
if (t1 === t0) t1 = t0 + 1;
const x = (t) => padL + ((t - t0) / (t1 - t0)) * (W - padL - padR);
const root = svg('svg', {
class: 'metric-chart', viewBox: `0 0 ${W} ${H}`, role: 'img',
'aria-label': `On/off timeline from ${when(iso(t0))} to ${when(iso(t1))}`,
});
root.appendChild(svg('text', { class: 'mc-label', x: padL, y: H - 4, 'text-anchor': 'start' }, when(iso(t0))));
root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1))));
series.forEach((s, i) => {
const top = padT + i * (rowH + rowGap);
const name = seriesName(s.labels);
// Without a sample the alert condition did not hold: the bare track.
root.appendChild(svg('rect', { class: 'mc-track', x: padL, y: top, width: W - padL - padR, height: rowH }));
if (series.length > 1) {
root.appendChild(svg('text', { class: 'mc-label', x: padL - 4, y: top + rowH / 2 + 3, 'text-anchor': 'end' }, `${i + 1}`));
}
for (const [from, to, v] of runsOf(s.points, data.step, t1)) {
const w = Math.max(x(to) - x(from), 1.5);
const on = v === 1;
const rect = svg('rect', {
class: on ? `mc-on s${i}` : 'mc-off',
x: x(from), y: on ? top : top + rowH / 2 - 1.5, width: w, height: on ? rowH : 3,
});
rect.appendChild(svg('title', {}, `${name}: ${v} from ${when(iso(from))} to ${when(iso(to))}`));
root.appendChild(rect);
}
});
addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec });
return root;
}
function chart(data, startSec, endSec) {
const W = 480;
const H = 130;
@@ -172,14 +265,7 @@ function chart(data, startSec, endSec) {
root.appendChild(svg('text', { class: 'mc-label', x: padL, y: H - 4, 'text-anchor': 'start' }, when(iso(t0))));
root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1))));
const marker = (t, cls, text) => {
if (t == null || t < t0 || t > t1) return;
const m = svg('line', { class: `mc-marker ${cls}`, x1: x(t), x2: x(t), y1: padT, y2: H - padB });
m.appendChild(svg('title', {}, `${text} ${when(iso(t))}`));
root.appendChild(m);
};
marker(startSec, 'start', 'Alert started');
marker(endSec, 'end', 'Incident resolved');
addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec });
series.forEach((s, i) => {
if (!s.points.length) return;
+3 -2
View File
@@ -10,6 +10,7 @@ import * as api from './api.js';
import { h, clear, badge, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
import { myID, currentTeam } from './state.js';
import { withAvatar } from './avatar.js';
const view = () => document.getElementById('view-oncall');
@@ -96,7 +97,7 @@ function nowCard() {
return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
withAvatar(h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), n.user_id),
h('div', {},
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
until && h('div', { class: 'hero-until', text: until }))));
@@ -143,7 +144,7 @@ function weekCard() {
},
h('span', { class: 'strip-name', text: dayName.format(d) }),
h('span', { class: 'strip-date', text: String(d.getDate()) }),
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
(el => (e ? withAvatar(el, e.user_id) : el))(h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' })),
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
+8
View File
@@ -2,6 +2,7 @@
// queue that drives the badges.
import * as api from './api.js';
import { noteAvatars, clearAvatars } from './avatar.js';
export const state = {
me: null, // { user, has_password }
@@ -70,6 +71,7 @@ let usersAt = 0;
export async function users() {
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
usersCache = await api.users();
noteAvatars(usersCache);
usersAt = Date.now();
}
return usersCache;
@@ -91,4 +93,10 @@ export function reset() {
state.open = [];
state.teams = [];
usersCache = null;
clearAvatars();
}
// After a picture changes: the cached list carries the old avatar_version.
export function forgetUsers() {
usersCache = null;
}
+2 -1
View File
@@ -20,6 +20,7 @@
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { withAvatar } from './avatar.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -466,7 +467,7 @@ function weekSheet(monday, byDate) {
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
? withAvatar(h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }), e.user_id)
: h('span', { class: 'rota-chip none' }));
}));