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]}`;
}