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

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:
2026-10-11 14:51:41 -04:00
co-authored by Claude Opus 5.5
parent ead0abf0cf
commit c1305a547d
22 changed files with 1148 additions and 38 deletions
+16
View File
@@ -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(
+1
View File
@@ -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,
+46
View File
@@ -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;
};
};
+12
View File
@@ -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');
});
});
+14
View File
@@ -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]}`;
}
+199 -2
View File
@@ -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>
+98 -3
View File
@@ -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) } }));