Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f4bf3765 | |||
| 6e95c86521 | |||
| cd3b2c2bea | |||
| 525947e809 | |||
| 536dbcaebe | |||
| 50e9ebcbc2 | |||
| 83837421e7 |
@@ -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.0
|
||||
appVersion: "v0.49.0"
|
||||
version: 0.51.0
|
||||
appVersion: "v0.51.0"
|
||||
|
||||
@@ -120,8 +120,11 @@ operatorMode: false
|
||||
# empty turns the feature off. Entries are host names, with "*" as one DNS label
|
||||
# and a leading "**" as one or more (e.g. "*.monitoring.svc.cluster.local",
|
||||
# "prometheus-*.monitoring"), IPs or CIDR ranges, each with an optional ":port".
|
||||
# The default is the Prometheus that kube-prometheus-stack installs; set it to
|
||||
# [] to turn the feature off.
|
||||
prometheus:
|
||||
allowedHosts: []
|
||||
allowedHosts:
|
||||
- kube-prometheus-stack-prometheus.kube-prometheus-stack:9090
|
||||
# How far before an alert started its graph begins.
|
||||
preroll: 1h
|
||||
timeout: 10s
|
||||
|
||||
@@ -30,7 +30,7 @@ over an administrator's edit.
|
||||
| `TERDUT_TRUSTED_PROXIES` | `1` | How many reverse proxies in front of the server append to `X-Forwarded-For`; the per-address rate limits use the entry that many hops from the right. `0` ignores the header |
|
||||
| `TERDUT_OPERATOR_KEY` | — | At least 32 characters. When set, the instance-scoped service account `terdut-operator` is created if missing and its `seed` key replaced with this value at every start — how terdut-operator authenticates without a bootstrap handshake. An instance-scoped account acts as owner of every team (team configuration) but is not a member of any, so it reads no incidents |
|
||||
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](./api.md#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
|
||||
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
|
||||
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — (the Helm chart defaults to `kube-prometheus-stack-prometheus.kube-prometheus-stack:9090`) | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
|
||||
| `TERDUT_PROMETHEUS_PREROLL` | `1h` | How far before an alert started its metrics graph begins |
|
||||
| `TERDUT_PROMETHEUS_TIMEOUT` | `10s` | Per-request timeout for a Prometheus query |
|
||||
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](./single-sign-on.md#single-sign-on-oidc) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -12,8 +12,9 @@ func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, want := range map[string]string{
|
||||
"js/incident.js": "copyIncident",
|
||||
"js/ui.js": "copy:",
|
||||
"js/incident.js": "copyIncident",
|
||||
"js/ui.js": "copy:",
|
||||
"js/metricchart.js": "isBool",
|
||||
} {
|
||||
b, err := fs.ReadFile(sub, file)
|
||||
if err != nil {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
})),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
)))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }));
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user