feat: the admin Overview shows who is listening, downloads, the library and its disk, and the server's version, uptime and recent errors; a download waiting on you, a client not answering or a full disk count in its verdict (M504 #5627 #5628 #5629 #5630)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The version moves to internal/buildinfo, so /rest/ping reports the build instead of "dev". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS play_events_started_idx;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AdminStatus> {
|
||||
return api.get<AdminStatus>('/api/admin/status');
|
||||
}
|
||||
|
||||
export function createAdminStatusQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.adminStatus(),
|
||||
queryFn: getAdminStatus,
|
||||
refetchInterval: 15_000
|
||||
});
|
||||
}
|
||||
|
||||
// Admin request queue -----------------------------------------------------
|
||||
|
||||
export async function listAdminRequests(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||
import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||
import type { Download } from '#lib/api/types.js';
|
||||
|
||||
// The admin Overview (#5605) answers "does anything need me?" first: a
|
||||
// verdict, then each queue that waits on you with how long its oldest has
|
||||
// waited, then the empty ones on one line (#189). The queues themselves
|
||||
// are worked on their own pages; the Overview no longer repeats them.
|
||||
//
|
||||
// Below the verdict, the server's state (M504): who is listening, what is
|
||||
// downloading, what the library holds and how much room is left, and the
|
||||
// server's version, uptime and recent errors. A download waiting on you, a
|
||||
// download client not answering, or a library folder unreadable or nearly
|
||||
// full also count in the verdict.
|
||||
|
||||
const summaryStore = createAdminSummaryQuery();
|
||||
const summaryQ = $derived($summaryStore);
|
||||
@@ -20,6 +28,60 @@
|
||||
// itself, and the jobs after it, are on Admin › Library (#5607).
|
||||
const scanStore = createScanStatusQuery();
|
||||
const scan = $derived($scanStore.data);
|
||||
|
||||
const statusStore = createAdminStatusQuery();
|
||||
const statusQ = $derived($statusStore);
|
||||
const status = $derived(statusQ.data);
|
||||
|
||||
// A folder under this share of free space counts as nearly full.
|
||||
const NEARLY_FULL = 0.05;
|
||||
const freeShare = (d: { total_bytes: number; free_bytes: number }) =>
|
||||
d.total_bytes > 0 ? d.free_bytes / d.total_bytes : 1;
|
||||
|
||||
type Problem = { key: string; text: string; since?: string | null; href: string };
|
||||
const statusProblems = $derived.by((): Problem[] => {
|
||||
if (!status) return [];
|
||||
const out: Problem[] = [];
|
||||
const dl = status.downloads;
|
||||
if (dl.needs_you > 0) {
|
||||
out.push({
|
||||
key: 'downloads',
|
||||
text: dl.needs_you === 1 ? '1 download needs you' : `${dl.needs_you} downloads need you`,
|
||||
since: dl.needs_you_since,
|
||||
href: '/collection/activity'
|
||||
});
|
||||
}
|
||||
for (const c of dl.clients_down) {
|
||||
out.push({ key: `client:${c.client}`, text: `${c.client} isn't answering: ${c.detail}`, since: c.since, href: '/admin/acquisition' });
|
||||
}
|
||||
if (status.server.dirty) {
|
||||
out.push({ key: 'migration', text: `The database migration to version ${status.server.migration} stopped part way`, href: '/admin' });
|
||||
}
|
||||
for (const d of status.library.disks) {
|
||||
if (d.error) out.push({ key: `disk:${d.path}`, text: `${d.path} can't be read: ${d.error}`, href: '/admin/library' });
|
||||
else if (freeShare(d) < NEARLY_FULL)
|
||||
out.push({ key: `disk:${d.path}`, text: `${d.path} is nearly full: ${formatBytes(d.free_bytes)} free`, href: '/admin/library' });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
|
||||
|
||||
function downloadLine(d: Download): string {
|
||||
switch (d.import_state) {
|
||||
case 'downloading':
|
||||
return `${Math.floor(d.progress * 100)}%`;
|
||||
case 'importing':
|
||||
return 'Checking and filing it';
|
||||
case 'waiting':
|
||||
return d.import_reason || 'Waiting to be imported';
|
||||
case 'review':
|
||||
case 'failed':
|
||||
return `Needs you: ${d.import_reason}`;
|
||||
default:
|
||||
return 'Starting';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
||||
@@ -36,7 +98,7 @@
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => summaryQ.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else}
|
||||
{@const problems = waiting.length + (scan?.error_message ? 1 : 0)}
|
||||
{@const problems = waiting.length + (scan?.error_message ? 1 : 0) + statusProblems.length}
|
||||
<p class="text-base {problems ? 'text-text-primary' : 'text-text-secondary'}" data-testid="verdict">
|
||||
{#if problems === 0}
|
||||
Nothing needs you.
|
||||
@@ -69,6 +131,17 @@
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#each statusProblems as p (p.key)}
|
||||
<li data-testid="needs-you">
|
||||
<a href={p.href} class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 hover:bg-surface-hover">
|
||||
<span class="text-sm text-text-primary">{p.text}</span>
|
||||
{#if p.since}
|
||||
<span class="text-xs text-text-muted">since {relativeTime(p.since)}</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-sm text-accent-fg">Open</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if clear.length > 0}
|
||||
@@ -78,5 +151,129 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if statusQ.isError}
|
||||
<p class="text-sm text-error-fg" data-testid="status-error">
|
||||
Couldn't read the server's status.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => statusQ.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if status}
|
||||
{@const l = status.listening}
|
||||
{@const dl = status.downloads}
|
||||
{@const lib = status.library}
|
||||
{@const srv = status.server}
|
||||
<section class="space-y-2" data-testid="overview-listening">
|
||||
<h2 class="text-sm font-medium text-text-primary">Listening now</h2>
|
||||
{#if l.now.length === 0}
|
||||
<p class="text-sm text-text-secondary">Nobody is listening.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each l.now as p (p.username + p.started_at)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 px-4 py-2" data-testid="listening-row">
|
||||
<span class="text-sm text-text-primary">{p.username}</span>
|
||||
<a href="/albums/{p.album_id}" class="min-w-0 truncate text-sm text-text-secondary hover:underline"
|
||||
>{p.title} · {p.artist_name}</a
|
||||
>
|
||||
<span class="ml-auto text-xs text-text-muted"
|
||||
>{p.client || p.device_class || 'a client'} · started {relativeTime(p.started_at)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="text-xs text-text-muted" data-testid="listening-day">
|
||||
{#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}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2" data-testid="overview-downloads">
|
||||
<h2 class="text-sm font-medium text-text-primary">Downloads</h2>
|
||||
<p class="text-sm text-text-secondary" data-testid="downloads-line">
|
||||
{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` : ''}.
|
||||
<a href="/collection/activity" class="text-accent-fg hover:underline">Activity</a>
|
||||
</p>
|
||||
{#if dl.live.length > 0}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each dl.live as d (d.id)}
|
||||
<li class="flex items-baseline gap-3 px-4 py-2" data-testid="download-row">
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text-primary" title={d.name}>
|
||||
{d.want_title ? `${d.want_title} · ${d.want_artist}` : d.name}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs {d.import_state === 'review' || d.import_state === 'failed' ? 'text-text-primary' : 'text-text-muted'}"
|
||||
>{downloadLine(d)} · {relativeTime(d.grabbed_at)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-2" data-testid="overview-library">
|
||||
<h2 class="text-sm font-medium text-text-primary">Library</h2>
|
||||
<p class="text-sm text-text-secondary" data-testid="library-line">
|
||||
{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}
|
||||
<a href="/admin/library" class="text-accent-fg hover:underline">Library</a>
|
||||
</p>
|
||||
{#each lib.disks as d (d.path)}
|
||||
{#if !d.error}
|
||||
{@const used = 1 - freeShare(d)}
|
||||
<div class="space-y-1" data-testid="disk">
|
||||
<div class="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span class="truncate text-text-secondary" title={d.path}
|
||||
>{#if d.data}Minstrel's data · {/if}<span class="font-mono">{d.path}</span></span
|
||||
>
|
||||
<span class="shrink-0 text-text-muted"
|
||||
>{formatBytes(d.free_bytes)} free of {formatBytes(d.total_bytes)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-surface-hover" aria-hidden="true">
|
||||
<div class="h-full rounded-full {freeShare(d) < NEARLY_FULL ? 'bg-error-fg' : 'bg-accent'}" style="width: {Math.round(used * 100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="space-y-2" data-testid="overview-server">
|
||||
<h2 class="text-sm font-medium text-text-primary">Server</h2>
|
||||
<p class="text-sm text-text-secondary" data-testid="server-line">
|
||||
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}
|
||||
</p>
|
||||
{#if srv.errors.length > 0}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each srv.errors as e, i (i)}
|
||||
<li class="space-y-0.5 px-4 py-2" data-testid="server-error">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span class="text-sm text-text-primary">{e.message}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-text-muted">{relativeTime(e.at)}</span>
|
||||
</div>
|
||||
{#if e.attrs}
|
||||
<p class="break-all font-mono text-xs text-text-muted">{e.attrs}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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> = {}): AdminSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn()) {
|
||||
function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn(), st?: AdminStatus) {
|
||||
(createAdminSummaryQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
data
|
||||
? mockQuery({ data, refetch })
|
||||
: mockQuery({ isError: true, error: new Error('boom'), refetch })
|
||||
);
|
||||
(createAdminStatusQuery as ReturnType<typeof vi.fn>).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> = {}): 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) } }));
|
||||
|
||||
Reference in New Issue
Block a user