diff --git a/Dockerfile b/Dockerfile index 91903306..0af41d99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,8 @@ ARG MINSTREL_VERSION=dev ARG MINSTREL_CHANNEL=local RUN go build -trimpath \ -ldflags="-s -w \ - -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \ - -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \ + -X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \ + -X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \ -o /out/minstrel ./cmd/minstrel FROM debian:bookworm-slim diff --git a/internal/api/admin_status.go b/internal/api/admin_status.go new file mode 100644 index 00000000..3a80d418 --- /dev/null +++ b/internal/api/admin_status.go @@ -0,0 +1,229 @@ +package api + +import ( + "net/http" + "syscall" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/logging" +) + +// The admin Overview's status sections (M504): after the verdict, who is +// listening, what is downloading, what the library holds and how much room +// it has, and how the server itself is doing. One read for the page, polled +// while it is open. + +type listeningView struct { + Username string `json:"username"` + TrackID string `json:"track_id"` + Title string `json:"title"` + ArtistName string `json:"artist_name"` + AlbumID string `json:"album_id"` + AlbumTitle string `json:"album_title"` + DurationMs int32 `json:"duration_ms"` + StartedAt string `json:"started_at"` + Client string `json:"client"` + DeviceClass string `json:"device_class"` +} + +type listeningStatusView struct { + Now []listeningView `json:"now"` + // The last day: how many plays, by how many people, the latest when. + PlaysDay int64 `json:"plays_day"` + ListenersDay int64 `json:"listeners_day"` + LastPlayAt *string `json:"last_play_at"` +} + +type downloadsStatusView struct { + Running int64 `json:"running"` + NeedsYou int64 `json:"needs_you"` + NeedsYouSince *string `json:"needs_you_since"` + ImportedDay int64 `json:"imported_day"` + GivenUpDay int64 `json:"given_up_day"` + // Live is the downloads not yet ended, the ones needing a person first. + Live []downloadView `json:"live"` + ClientsDown []clientDownView `json:"clients_down"` +} + +// diskView is the filesystem one library folder, or the data folder, sits +// on. +type diskView struct { + Path string `json:"path"` + // Data marks Minstrel's own data folder (covers, fingerprints), beside + // the library's. + Data bool `json:"data,omitempty"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` + // Error says why the folder could not be read, which is itself the + // news: a drive that did not mount. + Error string `json:"error,omitempty"` +} + +type libraryStatusView struct { + Tracks int64 `json:"tracks"` + Albums int64 `json:"albums"` + Artists int64 `json:"artists"` + Bytes int64 `json:"bytes"` + Disks []diskView `json:"disks"` +} + +type serverErrorView struct { + At string `json:"at"` + Message string `json:"message"` + Attrs string `json:"attrs"` +} + +type serverStatusView struct { + Version string `json:"version"` + Channel string `json:"channel"` + StartedAt string `json:"started_at"` + // Migration is the database schema's version, and Dirty whether its + // last migration stopped part way. + Migration int64 `json:"migration"` + Dirty bool `json:"dirty"` + // Errors is the newest few errors logged since the start, newest first; + // ErrorCount is all of them. + Errors []serverErrorView `json:"errors"` + ErrorCount int64 `json:"error_count"` +} + +type adminStatusView struct { + Listening listeningStatusView `json:"listening"` + Downloads downloadsStatusView `json:"downloads"` + Library libraryStatusView `json:"library"` + Server serverStatusView `json:"server"` +} + +// overviewLiveDownloads is how many running downloads the Overview lists; +// Activity has the rest. +const overviewLiveDownloads = 5 + +// overviewErrors is how many recent errors the Overview lists. +const overviewErrors = 10 + +// handleGetAdminStatus implements GET /api/admin/status. +func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) { + q := dbq.New(h.pool) + ctx := r.Context() + var out adminStatusView + + now, err := q.ListListeningNow(ctx) + if err != nil { + h.logger.Error("admin status: listening", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out.Listening.Now = make([]listeningView, 0, len(now)) + for _, p := range now { + out.Listening.Now = append(out.Listening.Now, listeningView{ + Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle, + ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle, + DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt), + Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass), + }) + } + day, err := q.PlaysLastDay(ctx) + if err != nil { + h.logger.Error("admin status: plays", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners + if day.LastAt.Valid { + at := formatTimestamp(day.LastAt) + out.Listening.LastPlayAt = &at + } + + if err := h.downloadsStatus(r, q, &out.Downloads); err != nil { + h.logger.Error("admin status: downloads", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + + lib, err := q.LibraryStatus(ctx) + if err != nil { + h.logger.Error("admin status: library", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists, + Bytes: lib.Bytes, Disks: []diskView{}} + if h.importer != nil { + for _, root := range h.importer.Roots { + out.Library.Disks = append(out.Library.Disks, diskOf(root)) + } + } + if h.dataDir != "" { + d := diskOf(h.dataDir) + d.Data = true + out.Library.Disks = append(out.Library.Disks, d) + } + + out.Server = serverStatus(logging.Errors) + // golang-migrate's own table, outside sqlc's schema. + if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`). + Scan(&out.Server.Migration, &out.Server.Dirty); err != nil { + h.logger.Warn("admin status: migration version", "err", err) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error { + st, err := q.DownloadsStatus(r.Context()) + if err != nil { + return err + } + *out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou, + ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay, + Live: []downloadView{}, ClientsDown: []clientDownView{}} + if st.NeedsYou > 0 && st.NeedsYouSince.Valid { + since := formatTimestamp(st.NeedsYouSince) + out.NeedsYouSince = &since + } + // ListGrabs puts the ones needing a person first, then the running ones. + rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads}) + if err != nil { + return err + } + for _, g := range rows { + if !g.EndedAt.Valid { + out.Live = append(out.Live, downloadFrom(g)) + } + } + down, err := q.ListClientsDown(r.Context()) + if err != nil { + return err + } + for _, c := range down { + out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail, + Since: formatTimestamp(c.FirstSeen)}) + } + return nil +} + +// diskOf reads the size and free space of the filesystem path is on. +func diskOf(path string) diskView { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return diskView{Path: path, Error: err.Error()} + } + bsize := uint64(st.Bsize) + return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize} +} + +func serverStatus(ring *logging.ErrorRing) serverStatusView { + kept, total := ring.Snapshot() + v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel, + StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total, + Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))} + for i, e := range kept { + if i == overviewErrors { + break + } + v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339), + Message: e.Message, Attrs: e.Attrs}) + } + return v +} diff --git a/internal/api/admin_status_test.go b/internal/api/admin_status_test.go new file mode 100644 index 00000000..8ead2f1c --- /dev/null +++ b/internal/api/admin_status_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo" + "git.fabledsword.com/bvandeusen/minstrel/internal/logging" +) + +// openPlay records a play by user of track that started ago and has not +// ended. +func openPlay(t *testing.T, h *handlers, user, track pgtype.UUID, ago string) { + t.Helper() + ctx := context.Background() + var session pgtype.UUID + if err := h.pool.QueryRow(ctx, + `INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id`, + user).Scan(&session); err != nil { + t.Fatalf("session: %v", err) + } + if _, err := h.pool.Exec(ctx, + `INSERT INTO play_events (user_id, track_id, session_id, started_at, client_id) + VALUES ($1, $2, $3, now() - $4::interval, 'web')`, user, track, session, ago); err != nil { + t.Fatalf("play: %v", err) + } +} + +// The Overview's status (M504): a play still inside its track's length is +// listening now; one long past it, never closed by its client, is not. +func TestAdminStatus_ListeningAndServer(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + admin := seedUser(t, pool, "admin", "pw", true) + + fresh, _ := seedTrackOnDisk(t, h, "statusfresh") // 30 s long + stale, _ := seedTrackOnDisk(t, h, "statusstale") + openPlay(t, h, admin.ID, fresh.ID, "10 seconds") + openPlay(t, h, admin.ID, stale.ID, "1 hour") + + req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/status", nil), admin) + w := httptest.NewRecorder() + h.handleGetAdminStatus(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var got adminStatusView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + var sawFresh, sawStale bool + for _, p := range got.Listening.Now { + switch p.TrackID { + case uuidToString(fresh.ID): + sawFresh = true + if p.Username != admin.Username || p.Client != "web" || p.Title != "AdmQ Track statusfresh" { + t.Errorf("listening row = %+v", p) + } + case uuidToString(stale.ID): + sawStale = true + } + } + if !sawFresh || sawStale { + t.Errorf("listening now: fresh %v (want true), stale %v (want false)", sawFresh, sawStale) + } + if got.Listening.PlaysDay < 2 || got.Listening.LastPlayAt == nil { + t.Errorf("last day: plays %d, last %v", got.Listening.PlaysDay, got.Listening.LastPlayAt) + } + + if got.Library.Tracks != 2 || got.Library.Albums != 2 || got.Library.Bytes != 10 { + t.Errorf("library = %+v, want 2 tracks, 2 albums, 10 bytes", got.Library) + } + if got.Downloads.Live == nil || got.Downloads.ClientsDown == nil || got.Library.Disks == nil { + t.Error("lists must be [], never null") + } + + if got.Server.Version != buildinfo.Version || got.Server.StartedAt == "" || got.Server.Migration < 92 { + t.Errorf("server = %+v", got.Server) + } + if _, total := logging.Errors.Snapshot(); got.Server.ErrorCount != total { + t.Errorf("error count = %d, ring says %d", got.Server.ErrorCount, total) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 1ffdaeef..bdb6ad8e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -238,6 +238,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password")) // The queues that wait on an admin, for the rail and the Overview (#5605). admin.Get("/summary", h.handleGetAdminSummary) + admin.Get("/status", h.handleGetAdminStatus) admin.Get("/requests", h.handleListAdminRequests) admin.Post("/requests/{id}/approve", h.handleApproveRequest) admin.Post("/requests/{id}/reject", h.handleRejectRequest) diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..04461efc --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,33 @@ +// Package buildinfo says which build is running and since when. It is the +// one place the version lives, so /healthz, the Subsonic envelope and the +// admin Overview cannot disagree about it. +package buildinfo + +import "time" + +// Version is the build's own version name — YYYY.MM.DD.HHMM, derived from +// the commit it was built from by ci/version.sh. Defaults to "dev" for local +// builds; overridden at link time via: +// +// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=2026.09.10.1449'" +// +// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile +// ldflag. +// +// This carried the literal strings "main" and "dev" until 2026-09-10, which +// made every image on a channel report the same thing forever. It stopped +// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule +// 145): this is now the ONLY thing that says which build is running. +var Version = "dev" + +// Channel is which line this build came off — "stable" or "dev", or "local" +// for a plain `docker build`. +// +// A SIBLING FIELD, never a suffix inside Version (family rule 149). The same +// commit built on both lanes reports the same version and differs only here; +// folding the two together is what makes a version string stop being +// comparable. +var Channel = "local" + +// Started is when this process started, for the Overview's uptime (M504). +var Started = time.Now() diff --git a/internal/db/dbq/admin_status.sql.go b/internal/db/dbq/admin_status.sql.go new file mode 100644 index 00000000..4738dbdc --- /dev/null +++ b/internal/db/dbq/admin_status.sql.go @@ -0,0 +1,168 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: admin_status.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const downloadsStatus = `-- name: DownloadsStatus :one +SELECT COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting', 'importing'))::bigint AS running, + COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS needs_you, + MIN(COALESCE(import_updated_at, grabbed_at)) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::timestamptz AS needs_you_since, + COUNT(*) FILTER (WHERE imported_at > now() - INTERVAL '24 hours')::bigint AS imported_day, + COUNT(*) FILTER (WHERE ended_at > now() - INTERVAL '24 hours' + AND end_reason IN ('stalled', 'failed', 'gone', 'abandoned'))::bigint AS given_up_day + FROM grabs + WHERE ended_at IS NULL + OR ended_at > now() - INTERVAL '24 hours' + OR imported_at > now() - INTERVAL '24 hours' +` + +type DownloadsStatusRow struct { + Running int64 + NeedsYou int64 + NeedsYouSince pgtype.Timestamptz + ImportedDay int64 + GivenUpDay int64 +} + +// Downloads at a glance: running, waiting on a person (and since when), and +// the last day's imports and give-ups. +func (q *Queries) DownloadsStatus(ctx context.Context) (DownloadsStatusRow, error) { + row := q.db.QueryRow(ctx, downloadsStatus) + var i DownloadsStatusRow + err := row.Scan( + &i.Running, + &i.NeedsYou, + &i.NeedsYouSince, + &i.ImportedDay, + &i.GivenUpDay, + ) + return i, err +} + +const libraryStatus = `-- name: LibraryStatus :one +SELECT COUNT(*)::bigint AS tracks, + COUNT(DISTINCT album_id)::bigint AS albums, + COUNT(DISTINCT artist_id)::bigint AS artists, + COALESCE(SUM(file_size), 0)::bigint AS bytes + FROM tracks + WHERE missing_since IS NULL +` + +type LibraryStatusRow struct { + Tracks int64 + Albums int64 + Artists int64 + Bytes int64 +} + +// What the library holds on disk: tracks whose files are present, the +// albums and artists they make, and their size. +func (q *Queries) LibraryStatus(ctx context.Context) (LibraryStatusRow, error) { + row := q.db.QueryRow(ctx, libraryStatus) + var i LibraryStatusRow + err := row.Scan( + &i.Tracks, + &i.Albums, + &i.Artists, + &i.Bytes, + ) + return i, err +} + +const listListeningNow = `-- name: ListListeningNow :many + +SELECT pe.started_at, pe.client_id, pe.device_class, + u.username, + t.id AS track_id, t.title AS track_title, t.duration_ms, + ar.name AS artist_name, + al.id AS album_id, al.title AS album_title + FROM play_events pe + JOIN users u ON u.id = pe.user_id + JOIN tracks t ON t.id = pe.track_id + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = t.artist_id + WHERE pe.ended_at IS NULL + AND pe.started_at > now() - INTERVAL '24 hours' + AND pe.started_at + make_interval(secs => t.duration_ms / 1000.0) + INTERVAL '5 minutes' > now() + ORDER BY pe.started_at DESC + LIMIT 20 +` + +type ListListeningNowRow struct { + StartedAt pgtype.Timestamptz + ClientID *string + DeviceClass *string + Username string + TrackID pgtype.UUID + TrackTitle string + DurationMs int32 + ArtistName string + AlbumID pgtype.UUID + AlbumTitle string +} + +// The admin Overview's status sections (M504): who is listening, what is +// downloading, what the library holds. Each is one cheap read. +// Plays in progress: open play events still inside their track's length, +// with a few minutes' grace for a pause. A client that never says a play +// ended would otherwise read as listening until the 24-hour sweep closes it. +func (q *Queries) ListListeningNow(ctx context.Context) ([]ListListeningNowRow, error) { + rows, err := q.db.Query(ctx, listListeningNow) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListListeningNowRow + for rows.Next() { + var i ListListeningNowRow + if err := rows.Scan( + &i.StartedAt, + &i.ClientID, + &i.DeviceClass, + &i.Username, + &i.TrackID, + &i.TrackTitle, + &i.DurationMs, + &i.ArtistName, + &i.AlbumID, + &i.AlbumTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const playsLastDay = `-- name: PlaysLastDay :one +SELECT COUNT(*)::bigint AS plays, + COUNT(DISTINCT user_id)::bigint AS listeners, + MAX(started_at)::timestamptz AS last_at + FROM play_events + WHERE started_at > now() - INTERVAL '24 hours' +` + +type PlaysLastDayRow struct { + Plays int64 + Listeners int64 + LastAt pgtype.Timestamptz +} + +// The last day's listening, for the line under Listening now. +func (q *Queries) PlaysLastDay(ctx context.Context) (PlaysLastDayRow, error) { + row := q.db.QueryRow(ctx, playsLastDay) + var i PlaysLastDayRow + err := row.Scan(&i.Plays, &i.Listeners, &i.LastAt) + return i, err +} diff --git a/internal/db/migrations/0092_play_events_started_idx.down.sql b/internal/db/migrations/0092_play_events_started_idx.down.sql new file mode 100644 index 00000000..62eafa51 --- /dev/null +++ b/internal/db/migrations/0092_play_events_started_idx.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS play_events_started_idx; diff --git a/internal/db/migrations/0092_play_events_started_idx.up.sql b/internal/db/migrations/0092_play_events_started_idx.up.sql new file mode 100644 index 00000000..c72abf73 --- /dev/null +++ b/internal/db/migrations/0092_play_events_started_idx.up.sql @@ -0,0 +1,4 @@ +-- The admin Overview (M504) reads plays across every user by time: the ones +-- in progress and the last day's count. The only index on play_events leads +-- with user_id, so without this every Overview load scanned the table. +CREATE INDEX play_events_started_idx ON play_events (started_at); diff --git a/internal/db/queries/admin_status.sql b/internal/db/queries/admin_status.sql new file mode 100644 index 00000000..f0d73827 --- /dev/null +++ b/internal/db/queries/admin_status.sql @@ -0,0 +1,54 @@ +-- The admin Overview's status sections (M504): who is listening, what is +-- downloading, what the library holds. Each is one cheap read. + +-- name: ListListeningNow :many +-- Plays in progress: open play events still inside their track's length, +-- with a few minutes' grace for a pause. A client that never says a play +-- ended would otherwise read as listening until the 24-hour sweep closes it. +SELECT pe.started_at, pe.client_id, pe.device_class, + u.username, + t.id AS track_id, t.title AS track_title, t.duration_ms, + ar.name AS artist_name, + al.id AS album_id, al.title AS album_title + FROM play_events pe + JOIN users u ON u.id = pe.user_id + JOIN tracks t ON t.id = pe.track_id + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = t.artist_id + WHERE pe.ended_at IS NULL + AND pe.started_at > now() - INTERVAL '24 hours' + AND pe.started_at + make_interval(secs => t.duration_ms / 1000.0) + INTERVAL '5 minutes' > now() + ORDER BY pe.started_at DESC + LIMIT 20; + +-- name: PlaysLastDay :one +-- The last day's listening, for the line under Listening now. +SELECT COUNT(*)::bigint AS plays, + COUNT(DISTINCT user_id)::bigint AS listeners, + MAX(started_at)::timestamptz AS last_at + FROM play_events + WHERE started_at > now() - INTERVAL '24 hours'; + +-- name: DownloadsStatus :one +-- Downloads at a glance: running, waiting on a person (and since when), and +-- the last day's imports and give-ups. +SELECT COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting', 'importing'))::bigint AS running, + COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS needs_you, + MIN(COALESCE(import_updated_at, grabbed_at)) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::timestamptz AS needs_you_since, + COUNT(*) FILTER (WHERE imported_at > now() - INTERVAL '24 hours')::bigint AS imported_day, + COUNT(*) FILTER (WHERE ended_at > now() - INTERVAL '24 hours' + AND end_reason IN ('stalled', 'failed', 'gone', 'abandoned'))::bigint AS given_up_day + FROM grabs + WHERE ended_at IS NULL + OR ended_at > now() - INTERVAL '24 hours' + OR imported_at > now() - INTERVAL '24 hours'; + +-- name: LibraryStatus :one +-- What the library holds on disk: tracks whose files are present, the +-- albums and artists they make, and their size. +SELECT COUNT(*)::bigint AS tracks, + COUNT(DISTINCT album_id)::bigint AS albums, + COUNT(DISTINCT artist_id)::bigint AS artists, + COALESCE(SUM(file_size), 0)::bigint AS bytes + FROM tracks + WHERE missing_since IS NULL; diff --git a/internal/logging/logging.go b/internal/logging/logging.go index d0561466..e0dc48b0 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -23,7 +23,8 @@ func New(w io.Writer, level, format string) (*slog.Logger, error) { default: return nil, fmt.Errorf("unknown log format %q (want text|json)", format) } - return slog.New(h), nil + // Errors are also kept for the admin Overview (M504). + return slog.New(newRingHandler(h, Errors)), nil } func parseLevel(s string) (slog.Level, error) { diff --git a/internal/logging/recent.go b/internal/logging/recent.go new file mode 100644 index 00000000..0aee1681 --- /dev/null +++ b/internal/logging/recent.go @@ -0,0 +1,122 @@ +package logging + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" +) + +// The server's recent errors, kept in memory for the admin Overview (M504): +// what has gone wrong since the process started, without reading the +// container's logs. Only ERROR and above, and only the newest few; the full +// record is still the log stream. + +// RecentErrorsKept is how many recent errors are kept. +const RecentErrorsKept = 50 + +// LoggedError is one error the server logged. +type LoggedError struct { + At time.Time + Message string + // Attrs is the record's attributes as key=value, the way the text log + // writes them. + Attrs string +} + +// ErrorRing keeps the newest errors and counts all of them. +type ErrorRing struct { + mu sync.Mutex + buf []LoggedError + next int + total int64 +} + +// Errors is the process's ring, filled by every logger New returns. +var Errors = NewErrorRing(RecentErrorsKept) + +// NewErrorRing returns a ring that keeps the newest n errors. +func NewErrorRing(n int) *ErrorRing { + return &ErrorRing{buf: make([]LoggedError, 0, n)} +} + +func (r *ErrorRing) add(e LoggedError) { + r.mu.Lock() + defer r.mu.Unlock() + r.total++ + if len(r.buf) < cap(r.buf) { + r.buf = append(r.buf, e) + return + } + r.buf[r.next] = e + r.next = (r.next + 1) % len(r.buf) +} + +// Snapshot returns the kept errors, newest first, and how many have been +// logged in all since the process started. +func (r *ErrorRing) Snapshot() ([]LoggedError, int64) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]LoggedError, 0, len(r.buf)) + for i := len(r.buf) - 1; i >= 0; i-- { + out = append(out, r.buf[(r.next+i)%len(r.buf)]) + } + return out, r.total +} + +// ringHandler passes every record on, and copies the errors into a ring. +type ringHandler struct { + inner slog.Handler + ring *ErrorRing + // attrs are the logger's own attributes (component=… and the like), + // already rendered, group-qualified. + attrs []string + groups []string +} + +func newRingHandler(inner slog.Handler, ring *ErrorRing) *ringHandler { + return &ringHandler{inner: inner, ring: ring} +} + +func (h *ringHandler) Enabled(ctx context.Context, l slog.Level) bool { + return h.inner.Enabled(ctx, l) +} + +func (h *ringHandler) Handle(ctx context.Context, rec slog.Record) error { + if rec.Level >= slog.LevelError { + parts := append([]string(nil), h.attrs...) + rec.Attrs(func(a slog.Attr) bool { + parts = append(parts, h.render(a)) + return true + }) + h.ring.add(LoggedError{At: rec.Time, Message: rec.Message, Attrs: strings.Join(parts, " ")}) + } + return h.inner.Handle(ctx, rec) +} + +func (h *ringHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + next := *h + next.inner = h.inner.WithAttrs(attrs) + next.attrs = append([]string(nil), h.attrs...) + for _, a := range attrs { + next.attrs = append(next.attrs, h.render(a)) + } + return &next +} + +func (h *ringHandler) WithGroup(name string) slog.Handler { + next := *h + next.inner = h.inner.WithGroup(name) + next.groups = append(append([]string(nil), h.groups...), name) + return &next +} + +func (h *ringHandler) render(a slog.Attr) string { + key := a.Key + if len(h.groups) > 0 { + key = strings.Join(h.groups, ".") + "." + key + } + return fmt.Sprintf("%s=%v", key, a.Value.Resolve()) +} diff --git a/internal/logging/recent_test.go b/internal/logging/recent_test.go new file mode 100644 index 00000000..dacbbbb7 --- /dev/null +++ b/internal/logging/recent_test.go @@ -0,0 +1,49 @@ +package logging + +import ( + "bytes" + "log/slog" + "testing" +) + +func TestRingHandler_KeepsErrorsNewestFirst(t *testing.T) { + ring := NewErrorRing(3) + var out bytes.Buffer + log := slog.New(newRingHandler(slog.NewTextHandler(&out, nil), ring)).With("component", "scan") + + log.Info("fine") + log.Warn("odd") + for _, msg := range []string{"one", "two", "three", "four"} { + log.WithGroup("job").Error(msg, "n", len(msg)) + } + + got, total := ring.Snapshot() + if total != 4 { + t.Errorf("total = %d, want 4 (info and warn are not errors)", total) + } + if len(got) != 3 { + t.Fatalf("kept %d, want 3", len(got)) + } + for i, want := range []string{"four", "three", "two"} { + if got[i].Message != want { + t.Errorf("kept[%d] = %q, want %q", i, got[i].Message, want) + } + } + if got[0].Attrs != "component=scan job.n=4" { + t.Errorf("attrs = %q", got[0].Attrs) + } + // Every record still reaches the log stream. + if n := bytes.Count(out.Bytes(), []byte("\n")); n != 6 { + t.Errorf("log lines = %d, want 6", n) + } +} + +func TestErrorRing_BeforeItFills(t *testing.T) { + ring := NewErrorRing(5) + ring.add(LoggedError{Message: "a"}) + ring.add(LoggedError{Message: "b"}) + got, total := ring.Snapshot() + if total != 2 || len(got) != 2 || got[0].Message != "b" || got[1].Message != "a" { + t.Errorf("got %+v total %d, want b, a", got, total) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b96c420a..17d51891 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/api" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" @@ -232,8 +233,8 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{ "status": "ok", - "version": ServerVersion, - "channel": ServerChannel, + "version": buildinfo.Version, + "channel": buildinfo.Channel, "min_client_version": MinClientVersion, }) } diff --git a/internal/server/version.go b/internal/server/version.go index efc2399e..5c120a0c 100644 --- a/internal/server/version.go +++ b/internal/server/version.go @@ -5,27 +5,5 @@ package server // older clients see version_too_old at /healthz and refuse to operate. const MinClientVersion = "0.1.0" -// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived -// from the commit it was built from by ci/version.sh. Defaults to "dev" for -// local builds; overridden at link time via: -// -// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'" -// -// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile -// ldflag. Surfaced at /healthz so operators can verify which image their -// container is running without exec'ing into it. -// -// This carried the literal strings "main" and "dev" until 2026-09-10, which -// made every image on a channel report the same thing forever. It stopped -// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule -// 145): this is now the ONLY thing that says which build is running. -var ServerVersion = "dev" - -// ServerChannel is which line this build came off — "stable" or "dev", or -// "local" for a plain `docker build`. -// -// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The -// same commit built on both lanes reports the same version and differs only -// here; folding the two together is what makes a version string stop being -// comparable. -var ServerChannel = "local" +// The build's version and channel live in internal/buildinfo, shared with +// the Subsonic envelope and the admin Overview (M504). diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go index ffbf4255..4bcd20ed 100644 --- a/internal/subsonic/envelope.go +++ b/internal/subsonic/envelope.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "strings" + + "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo" ) // APIVersion is the Subsonic REST version we advertise. 1.16.1 is the last @@ -18,9 +20,6 @@ const ( XMLNS = "http://subsonic.org/restapi" ) -// ServerVersion identifies this build. Overwritten by cmd/minstrel at startup. -var ServerVersion = "dev" - // Envelope holds the attributes present on every Subsonic response. Concrete // response types embed it and add endpoint-specific fields with json+xml tags. type Envelope struct { @@ -48,7 +47,7 @@ func NewEnvelope(status string) Envelope { Status: status, Version: APIVersion, Type: ServerType, - ServerVersion: ServerVersion, + ServerVersion: buildinfo.Version, OpenSubsonic: true, } } diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 2acddc22..4f6217ee 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -12,6 +12,7 @@ import type { ResolvedDuplicatesResponse, AdminPlaybackError, AdminQuarantineRow, + AdminStatus, AdminSummary, QuarantineActionRow, MusicRequest, @@ -36,6 +37,21 @@ export function createAdminSummaryQuery() { }); } +// Admin status (M504): who is listening, downloads, the library and disk, +// and the server, for the Overview below its verdict. Polled while open. + +export async function getAdminStatus(): Promise { + return api.get('/api/admin/status'); +} + +export function createAdminStatusQuery() { + return createQuery({ + queryKey: qk.adminStatus(), + queryFn: getAdminStatus, + refetchInterval: 15_000 + }); +} + // Admin request queue ----------------------------------------------------- export async function listAdminRequests( diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 16a2d521..09706f4a 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -60,6 +60,7 @@ export const qk = { ['adminPlaybackErrors', { resolved: resolved ?? false }] as const, scanStatus: () => ['scanStatus'] as const, adminSummary: () => ['adminSummary'] as const, + adminStatus: () => ['adminStatus'] as const, coverage: () => ['coverage'] as const, fingerprintCoverage: () => ['fingerprintCoverage'] as const, coverProviders: () => ['coverProviders'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2981ee08..af84ca84 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -879,3 +879,49 @@ export type AdminSummary = { suspect: AdminQueue; missing: AdminQueue; }; + +// What GET /api/admin/status returns (M504): the Overview's status sections. +export type AdminListening = { + username: string; + track_id: string; + title: string; + artist_name: string; + album_id: string; + album_title: string; + duration_ms: number; + started_at: string; + client: string; + device_class: string; +}; +// data marks Minstrel's own data folder, beside the library's folders. +export type AdminDisk = { path: string; data?: boolean; total_bytes: number; free_bytes: number; error?: string }; +export type AdminServerError = { at: string; message: string; attrs: string }; +export type AdminStatus = { + listening: { + now: AdminListening[]; + plays_day: number; + listeners_day: number; + last_play_at: string | null; + }; + downloads: { + running: number; + needs_you: number; + needs_you_since: string | null; + imported_day: number; + given_up_day: number; + live: Download[]; + clients_down: { client: string; detail: string; since: string }[]; + }; + library: { tracks: number; albums: number; artists: number; bytes: number; disks: AdminDisk[] }; + server: { + version: string; + channel: string; + started_at: string; + // The database schema's version, and whether its last migration stopped + // part way. + migration: number; + dirty: boolean; + errors: AdminServerError[]; + error_count: number; + }; +}; diff --git a/web/src/lib/utils/bytes.test.ts b/web/src/lib/utils/bytes.test.ts new file mode 100644 index 00000000..32ed3b21 --- /dev/null +++ b/web/src/lib/utils/bytes.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'vitest'; +import { formatBytes } from './bytes'; + +describe('formatBytes', () => { + test('picks the unit that keeps the figure above one', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(999)).toBe('999 B'); + expect(formatBytes(12_300)).toBe('12.3 KB'); + expect(formatBytes(820_000_000_000)).toBe('820 GB'); + expect(formatBytes(1_400_000_000_000)).toBe('1.4 TB'); + }); +}); diff --git a/web/src/lib/utils/bytes.ts b/web/src/lib/utils/bytes.ts new file mode 100644 index 00000000..dddd313d --- /dev/null +++ b/web/src/lib/utils/bytes.ts @@ -0,0 +1,14 @@ +// A size in bytes, in the largest unit that keeps it above one: "1.4 TB", +// "820 GB", "12 KB". Decimal units, as drives are sold and as `df -H` shows. +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + +export function formatBytes(n: number): string { + let v = n; + let u = 0; + while (v >= 1000 && u < UNITS.length - 1) { + v /= 1000; + u++; + } + const digits = u === 0 || v >= 100 ? 0 : 1; + return `${v.toFixed(digits)} ${UNITS[u]}`; +} diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 3b77b7a9..99f7f11f 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,13 +1,21 @@ {pageTitle('Admin · Overview')} @@ -36,7 +98,7 @@

{:else} - {@const problems = waiting.length + (scan?.error_message ? 1 : 0)} + {@const problems = waiting.length + (scan?.error_message ? 1 : 0) + statusProblems.length}

{#if problems === 0} Nothing needs you. @@ -69,6 +131,17 @@ {/each} + {#each statusProblems as p (p.key)} +

  • + + {p.text} + {#if p.since} + since {relativeTime(p.since)} + {/if} + Open + +
  • + {/each} {/if} {#if clear.length > 0} @@ -78,5 +151,129 @@ {/if} {/if} + + {#if statusQ.isError} +

    + Couldn't read the server's status. + +

    + {:else if status} + {@const l = status.listening} + {@const dl = status.downloads} + {@const lib = status.library} + {@const srv = status.server} +
    +

    Listening now

    + {#if l.now.length === 0} +

    Nobody is listening.

    + {:else} +
      + {#each l.now as p (p.username + p.started_at)} +
    • + {p.username} + {p.title} · {p.artist_name} + {p.client || p.device_class || 'a client'} · started {relativeTime(p.started_at)} +
    • + {/each} +
    + {/if} +

    + {#if l.plays_day > 0} + {plural(l.plays_day, 'play', 'plays')} by {plural(l.listeners_day, 'person', 'people')} in the last day{#if l.last_play_at}, + the latest {relativeTime(l.last_play_at)}{/if}. + {:else} + No plays in the last day. + {/if} +

    +
    + +
    +

    Downloads

    +

    + {dl.running === 0 ? 'Nothing downloading' : `${dl.running} downloading`}. + In the last day, {dl.imported_day} imported{dl.given_up_day ? ` and ${dl.given_up_day} given up` : ''}. + Activity +

    + {#if dl.live.length > 0} +
      + {#each dl.live as d (d.id)} +
    • + + {d.want_title ? `${d.want_title} · ${d.want_artist}` : d.name} + + {downloadLine(d)} · {relativeTime(d.grabbed_at)} +
    • + {/each} +
    + {/if} +
    + +
    +

    Library

    +

    + {plural(lib.tracks, 'track', 'tracks')} on {plural(lib.albums, 'album', 'albums')} by {plural(lib.artists, 'artist', 'artists')}, + {formatBytes(lib.bytes)}. + {#if scan?.in_flight} + Scanning now. + {:else if scan?.finished_at} + Last scanned {relativeTime(scan.finished_at)}. + {/if} + Library +

    + {#each lib.disks as d (d.path)} + {#if !d.error} + {@const used = 1 - freeShare(d)} +
    +
    + {#if d.data}Minstrel's data · {/if}{d.path} + {formatBytes(d.free_bytes)} free of {formatBytes(d.total_bytes)} +
    + +
    + {/if} + {/each} +
    + +
    +

    Server

    +

    + Version {srv.version} ({srv.channel}), database schema {srv.migration}, started {relativeTime(srv.started_at)}. + {#if srv.error_count === 0} + No errors since. + {:else} + {plural(srv.error_count, 'error', 'errors')} logged since{srv.error_count > srv.errors.length + ? `; the latest ${srv.errors.length}:` + : ':'} + {/if} +

    + {#if srv.errors.length > 0} +
      + {#each srv.errors as e, i (i)} +
    • +
      + {e.message} + {relativeTime(e.at)} +
      + {#if e.attrs} +

      {e.attrs}

      + {/if} +
    • + {/each} +
    + {/if} +
    + {/if} diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index 0fddd500..0c020bb2 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -20,6 +20,7 @@ vi.mock('#lib/api/admin.js', async () => { const { readable } = await import('svelte/store'); return { createAdminSummaryQuery: vi.fn(), + createAdminStatusQuery: vi.fn(), createScanStatusQuery: vi.fn(() => readable({ data: { id: '', started_at: '', finished_at: null, in_flight: false }, @@ -65,8 +66,8 @@ describe('admin/+layout.ts load gate', () => { // where the page picked up empty data despite `mockReturnValue` being set. // Importing at module load means the mocks attach to the same handles the // page binds against. -import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js'; -import type { AdminSummary } from '#lib/api/types.js'; +import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js'; +import type { AdminStatus, AdminSummary } from '#lib/api/types.js'; import OverviewPage from './+page.svelte'; import AdminLayout from './+layout.svelte'; import { createRawSnippet } from 'svelte'; @@ -86,14 +87,40 @@ function summary(over: Partial = {}): AdminSummary { }; } -function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn()) { +function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn(), st?: AdminStatus) { (createAdminSummaryQuery as ReturnType).mockReturnValue( data ? mockQuery({ data, refetch }) : mockQuery({ isError: true, error: new Error('boom'), refetch }) ); + (createAdminStatusQuery as ReturnType).mockReturnValue( + st ? mockQuery({ data: st }) : mockQuery({ isPending: true }) + ); } +const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString(); + +function status(over: Partial = {}): AdminStatus { + return { + listening: { now: [], plays_day: 0, listeners_day: 0, last_play_at: null }, + downloads: { + running: 0, needs_you: 0, needs_you_since: null, imported_day: 0, given_up_day: 0, live: [], clients_down: [] + }, + library: { + tracks: 41_000, albums: 3_200, artists: 900, bytes: 1_400_000_000_000, + disks: [{ path: '/music', total_bytes: 4_000_000_000_000, free_bytes: 2_000_000_000_000 }] + }, + server: { + version: '2026.10.11.1840', channel: 'dev', started_at: minutesAgo(180), migration: 92, dirty: false, + errors: [], error_count: 0 + }, + ...over + }; +} + +// The text of a testid'd element with its whitespace folded. +const textOf = (id: string) => (screen.getByTestId(id).textContent ?? '').replace(/\s+/g, ' ').trim(); + describe('admin Overview page', () => { test('with every queue empty, the verdict says nothing needs you, and names what is clear', () => { setupOverview(); @@ -154,6 +181,74 @@ describe('admin Overview page', () => { }); }); +// Below the verdict, the server's state (M504). +describe('admin Overview status', () => { + test('who is listening, and the last day of plays', () => { + setupOverview(summary(), vi.fn(), status({ + listening: { + now: [{ + username: 'bryan', track_id: 't1', title: 'Avril 14th', artist_name: 'Aphex Twin', album_id: 'al1', + album_title: 'Drukqs', duration_ms: 125_000, started_at: minutesAgo(1), client: 'android', device_class: 'phone' + }], + plays_day: 37, listeners_day: 2, last_play_at: minutesAgo(1) + } + })); + render(OverviewPage); + const rows = screen.getAllByTestId('listening-row'); + expect(rows).toHaveLength(1); + expect(rows[0].textContent?.replace(/\s+/g, ' ')).toMatch(/bryan Avril 14th · Aphex Twin android · started 1m ago/); + expect(textOf('listening-day')).toBe('37 plays by 2 people in the last day, the latest 1m ago.'); + }); + + test('nobody listening says so', () => { + setupOverview(summary(), vi.fn(), status()); + render(OverviewPage); + expect(screen.getByText('Nobody is listening.')).toBeTruthy(); + expect(textOf('listening-day')).toBe('No plays in the last day.'); + }); + + test('the library, its size, and the room left on its disk', () => { + setupOverview(summary(), vi.fn(), status()); + render(OverviewPage); + expect(textOf('library-line')).toMatch(/^41,000 tracks on 3,200 albums by 900 artists, 1.4 TB\./); + expect(textOf('disk')).toBe('/music 2.0 TB free of 4.0 TB'); + }); + + test('the server says its version, when it started, and its recent errors', () => { + setupOverview(summary(), vi.fn(), status({ + server: { + version: '2026.10.11.1840', channel: 'dev', started_at: minutesAgo(180), migration: 92, dirty: false, error_count: 3, + errors: [{ at: minutesAgo(5), message: 'admin: summary', attrs: 'err=boom' }] + } + })); + render(OverviewPage); + expect(textOf('server-line')).toBe('Version 2026.10.11.1840 (dev), database schema 92, started 3h ago. 3 errors logged since; the latest 1:'); + expect(screen.getAllByTestId('server-error')[0].textContent).toMatch(/admin: summary[\s\S]*err=boom/); + }); + + test('a download waiting on you, a client not answering and a nearly full disk count in the verdict', () => { + setupOverview(summary(), vi.fn(), status({ + downloads: { + running: 1, needs_you: 2, needs_you_since: minutesAgo(90), imported_day: 4, given_up_day: 0, live: [], + clients_down: [{ client: 'qbittorrent', detail: 'connection refused', since: minutesAgo(30) }] + }, + library: { + tracks: 1, albums: 1, artists: 1, bytes: 1, + disks: [{ path: '/music', total_bytes: 1_000, free_bytes: 10 }] + } + })); + render(OverviewPage); + expect(screen.getByTestId('verdict').textContent).toMatch(/3 things need you/); + const needs = screen.getAllByTestId('needs-you').map((li) => li.textContent?.replace(/\s+/g, ' ').trim()); + expect(needs).toEqual([ + '2 downloads need you since 1h ago Open', + "qbittorrent isn't answering: connection refused since 30m ago Open", + '/music is nearly full: 10 B free Open' + ]); + expect(textOf('downloads-line')).toBe('1 downloading. In the last day, 4 imported. Activity'); + }); +}); + describe('admin layout', () => { test('the sections run down a rail, each queue carrying how many wait on you', () => { setupOverview(summary({ requests: { count: 2, since: ago(1) }, duplicates: { count: 5, since: ago(4) } }));