feat: Admin is a left rail with how many wait on you in each queue, and the Overview opens with a verdict, the queues that need you and since when, then the clear ones on one line (M503 #5605)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped

- GET /api/admin/summary: every admin queue (requests, reported tracks,
  playback errors, duplicates, suspect sources, missing files) with its
  count and the age of its oldest item, in one query.
- The admin layout drops the 11-tab strip for SectionRail, as Settings and
  the Collection have, with each queue's count as its badge; the counts
  refresh on each admin navigation and every 30s.
- The Overview leads with "Nothing needs you." or "N things need you.",
  lists each waiting queue with "oldest 3d ago" and a link to it, counts a
  failed library scan as a problem, and names the empty queues on one line.
  The request and quarantine previews and the stat tiles are gone; those
  queues are worked on their own pages. A failed summary says so with Try
  again instead of reading as all-clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 09:58:13 -04:00
co-authored by Claude Opus 5.5
parent 7c89e59536
commit ace7e22e6a
15 changed files with 485 additions and 623 deletions
+33
View File
@@ -0,0 +1,33 @@
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists
// them. The rail and the Overview read the same list, so a queue that
// needs you is counted the same in both places.
export type QueueKey = keyof AdminSummary;
export type QueueDef = {
key: QueueKey;
href: string;
// How the Overview names a count of them: "2 requests to decide".
one: string;
many: string;
// How the Overview's healthy line names an empty one: "no requests".
none: string;
};
export const QUEUES: QueueDef[] = [
{ key: 'requests', href: '/admin/requests', one: 'request to decide', many: 'requests to decide', none: 'requests' },
{ key: 'quarantined', href: '/admin/quarantine', one: 'reported track', many: 'reported tracks', none: 'reports' },
{ key: 'playback_errors', href: '/admin/playback-errors', one: 'playback error', many: 'playback errors', none: 'playback errors' },
{ key: 'duplicates', href: '/admin/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
{ key: 'suspect', href: '/admin/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
];
export function countOf(summary: AdminSummary | undefined, key: QueueKey): number {
return (summary?.[key] as AdminQueue | undefined)?.count ?? 0;
}
export function queueLabel(def: QueueDef, n: number): string {
return `${n} ${n === 1 ? def.one : def.many}`;
}
+18
View File
@@ -12,6 +12,7 @@ import type {
ResolvedDuplicatesResponse,
AdminPlaybackError,
AdminQuarantineRow,
AdminSummary,
LidarrConfig,
LidarrMetadataProfile,
LidarrQualityProfile,
@@ -23,6 +24,23 @@ import type {
PlaybackErrorResolution
} from './types';
// Admin summary (#5605) ---------------------------------------------------
export async function getAdminSummary(): Promise<AdminSummary> {
return api.get<AdminSummary>('/api/admin/summary');
}
// The admin rail's counts and the Overview's verdict. Refetched on every
// admin navigation by the layout and every 30s while an admin page is open,
// so a queue someone just cleared doesn't keep its count for long.
export function createAdminSummaryQuery() {
return createQuery({
queryKey: qk.adminSummary(),
queryFn: getAdminSummary,
refetchInterval: 30_000
});
}
// Admin Lidarr config -----------------------------------------------------
export async function getLidarrConfig(): Promise<LidarrConfig> {
+1
View File
@@ -63,6 +63,7 @@ export const qk = {
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const,
adminSummary: () => ['adminSummary'] as const,
coverage: () => ['coverage'] as const,
fingerprintCoverage: () => ['fingerprintCoverage'] as const,
coverProviders: () => ['coverProviders'] as const,
+12
View File
@@ -900,3 +900,15 @@ export type ReleaseProfilesResponse = {
primary_types: string[];
secondary_types: string[];
};
// What GET /api/admin/summary returns (#5605): each queue that waits on an
// admin, with how many and since when the oldest has waited (null when empty).
export type AdminQueue = { count: number; since: string | null };
export type AdminSummary = {
requests: AdminQueue;
quarantined: AdminQueue;
playback_errors: AdminQueue;
duplicates: AdminQueue;
suspect: AdminQueue;
missing: AdminQueue;
};
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
type Item = { href: string; label: string };
const items: Item[] = [
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' },
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' },
{ href: '/admin/users', label: 'Users' }
];
function isActive(href: string): boolean {
if (href === '/admin') return page.url.pathname === '/admin';
return page.url.pathname.startsWith(href);
}
</script>
<nav aria-label="Admin sections" class="sticky top-0 z-10 border-b border-border bg-background">
<ul class="flex gap-2">
{#each items as item (item.href)}
<li>
<a
href={item.href}
aria-current={isActive(item.href) ? 'page' : undefined}
class="block border-b-2 px-3 py-2 text-sm transition-colors {isActive(item.href)
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
-79
View File
@@ -1,79 +0,0 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/admin')
}));
vi.mock('$app/state', () => pageUrlModule(state));
import AdminTabs from './AdminTabs.svelte';
describe('AdminTabs', () => {
test('Overview tab is current when on /admin', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const overview = screen.getByRole('link', { name: /overview/i });
expect(overview).toHaveAttribute('aria-current', 'page');
});
test('Integrations tab is current when on /admin/integrations', () => {
state.pageUrl = new URL('http://localhost/admin/integrations');
render(AdminTabs);
expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute(
'aria-current',
'page'
);
expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current');
});
test('Requests tab is current when on /admin/requests', () => {
state.pageUrl = new URL('http://localhost/admin/requests');
render(AdminTabs);
expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page');
});
test('Quarantine tab is current when on /admin/quarantine', () => {
state.pageUrl = new URL('http://localhost/admin/quarantine');
render(AdminTabs);
expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute(
'aria-current',
'page'
);
});
test('Users tab is current when on /admin/users', () => {
state.pageUrl = new URL('http://localhost/admin/users');
render(AdminTabs);
expect(screen.getByRole('link', { name: /users/i })).toHaveAttribute(
'aria-current',
'page'
);
});
test('renders all eleven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
expect(links.map((l) => l.textContent?.trim())).toEqual([
'Overview',
'Integrations',
'Requests',
'Quarantine',
// Missing files sits with Quarantine and Playback errors: the three
// surfaces that show tracks needing an operator's attention.
'Missing files',
// Duplicates follows Missing files: both are library-health reports on
// what the library holds, rather than a queue of user reports.
'Duplicates',
// Suspect sources is the third library-health report: files whose
// names say they were ripped from a video.
'Suspect sources',
'Playback errors',
'Diagnostics',
'Tuning',
'Users'
]);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts" generics="Id extends string">
// In-page tabs: one strip of buttons switching what a single page shows
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
// navigation and so stay links; this is for state inside one page, such as
// (#5416). The same look as DiscoverTabs, which is route
// navigation and so stays links; this is for state inside one page, such as
// Liked's Artists | Albums | Tracks or a queue's status filter.
type Item = {
id: Id;
+49 -4
View File
@@ -1,10 +1,55 @@
<script lang="ts">
import AdminTabs from '#lib/components/AdminTabs.svelte';
import { untrack } from 'svelte';
import { page } from '$app/state';
import SectionRail, { type RailItem } from '#lib/components/SectionRail.svelte';
import { createAdminSummaryQuery } from '#lib/api/admin.js';
import { countOf } from '#lib/admin/queues.js';
let { children } = $props<{ children: import('svelte').Snippet }>();
// Admin (#5605): one page per section down a left rail, as Settings and
// the Collection are, so the sections sit in the same place everywhere
// and fit a phone. Each queue carries how many wait on you, from one
// summary read that is refreshed whenever you move between admin pages.
const summaryStore = createAdminSummaryQuery();
const summary = $derived($summaryStore.data);
// Refetch when the admin moves to another admin page, where they may have
// just cleared something. Not on first mount (the query is fetching
// already), and untracked, so the refetch's own result doesn't loop.
let lastPath = page.url.pathname;
$effect(() => {
const path = page.url.pathname;
if (path === lastPath) return;
lastPath = path;
untrack(() => $summaryStore.refetch?.());
});
const items: RailItem[] = $derived([
{ href: '/admin', label: 'Overview', exact: true },
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
{ href: '/admin/quarantine', label: 'Quarantine', attention: countOf(summary, 'quarantined') },
{ href: '/admin/playback-errors', label: 'Playback errors', attention: countOf(summary, 'playback_errors') },
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' }
]);
</script>
<div class="space-y-6">
<AdminTabs />
{@render children?.()}
<div class="space-y-4">
<h1 class="font-display text-2xl font-medium text-text-primary">Admin</h1>
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
<div class="mb-4 md:mb-0">
<div class="md:sticky md:top-4">
<SectionRail label="Admin sections" {items} />
</div>
</div>
<div class="min-w-0 space-y-6">
{@render children?.()}
</div>
</div>
</div>
+58 -313
View File
@@ -1,22 +1,14 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
import StageBadge from '#lib/components/StageBadge.svelte';
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
import { stageState } from './stage-state';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminRequestsQuery,
createLidarrConfigQuery,
createAdminQuarantineQuery,
createAdminSummaryQuery,
createScanStatusQuery,
createCoverageQuery,
createFingerprintCoverageQuery,
approveRequest,
rejectRequest,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr,
refetchMissingCovers,
triggerScan,
researchMissingArt
@@ -24,158 +16,22 @@
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { runMutation } from '#lib/util/runMutation.js';
import type {
AdminQuarantineRow,
MusicRequest,
RequestKind,
LidarrQuarantineReason
} from '#lib/api/types.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
// Top-of-admin overview. Shows the connection card + the most recent
// 5 of each actionable queue (requests, quarantine) with inline
// handlers for the common case. The dedicated pages keep the full
// modal flows (override, typed-confirm); the overview offers a faster
// path for the common-case actions, with a two-click confirm on
// destructive quarantine actions to avoid modal duplication.
const PREVIEW_LIMIT = 5;
// 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.
const client = useQueryClient();
const requestsStore = createAdminRequestsQuery('pending');
const requests = $derived($requestsStore);
const summaryStore = createAdminSummaryQuery();
const summaryQ = $derived($summaryStore);
const summary = $derived(summaryQ.data);
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
const quarantineStore = createAdminQuarantineQuery();
const quarantine = $derived($quarantineStore);
const pendingCount = $derived(requests.data?.length ?? 0);
const quarantineCount = $derived(quarantine.data?.length ?? 0);
const lidarrConnected = $derived(config.data?.enabled === true);
// A tile whose query failed shows a dash, not 0: a failed load must not
// read as an all-clear (#5604).
function tile(q: { isPending: boolean; isError: boolean }, n: number): string {
return q.isPending || q.isError ? '—' : String(n);
}
const requestPreview = $derived(
(requests.data ?? []).slice(0, PREVIEW_LIMIT)
);
const quarantinePreview = $derived(
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
);
// ---- Two-click destructive confirm for quarantine actions ----
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
// click within 3s fires the action; otherwise the key clears.
let confirmingKey = $state<string | null>(null);
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
function confirmKey(trackID: string, action: 'delete-file' | 'delete-lidarr'): string {
return `${trackID}:${action}`;
}
function armConfirm(trackID: string, action: 'delete-file' | 'delete-lidarr') {
confirmingKey = confirmKey(trackID, action);
if (confirmTimer) clearTimeout(confirmTimer);
confirmTimer = setTimeout(() => { confirmingKey = null; }, 3000);
}
function clearConfirm() {
confirmingKey = null;
if (confirmTimer) clearTimeout(confirmTimer);
}
// ---- Helpers ----
function fallbackIcon(kind: RequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
function rowSubtitle(r: MusicRequest): string {
if (r.kind === 'artist') return 'Artist';
if (r.kind === 'album') return `Album · ${r.artist_name}`;
return `Track · ${r.artist_name}`;
}
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
bad_rip: 'Bad rip',
wrong_file: 'Wrong file',
wrong_tags: 'Wrong tags',
duplicate: 'Duplicate',
other: 'Other'
};
// ---- Action handlers ----
async function invalidateRequests() {
await Promise.all([
// An approved request becomes a want, so the Collection's lists and
// counts change too (#5604).
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: qk.adminRequests('pending') }),
client.invalidateQueries({ queryKey: qk.adminRequests('approved') }),
client.invalidateQueries({ queryKey: qk.adminRequests('rejected') }),
client.invalidateQueries({ queryKey: qk.adminRequests('completed') })
]);
}
async function onApprove(r: MusicRequest) {
await runMutation(async () => {
await approveRequest(r.id);
await invalidateRequests();
});
}
async function onReject(r: MusicRequest) {
await runMutation(async () => {
await rejectRequest(r.id);
await invalidateRequests();
});
}
async function invalidateQuarantine() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
client.invalidateQueries({ queryKey: qk.adminQuarantineActions() })
]);
}
async function onResolve(row: AdminQuarantineRow) {
await runMutation(async () => {
await resolveQuarantine(row.track_id);
await invalidateQuarantine();
});
}
async function onDeleteFile(row: AdminQuarantineRow) {
const key = confirmKey(row.track_id, 'delete-file');
if (confirmingKey !== key) {
armConfirm(row.track_id, 'delete-file');
return;
}
clearConfirm();
await runMutation(async () => {
await deleteQuarantineFile(row.track_id);
await invalidateQuarantine();
});
}
async function onDeleteLidarr(row: AdminQuarantineRow) {
const key = confirmKey(row.track_id, 'delete-lidarr');
if (confirmingKey !== key) {
armConfirm(row.track_id, 'delete-lidarr');
return;
}
clearConfirm();
await runMutation(async () => {
await deleteQuarantineViaLidarr(row.track_id);
await invalidateQuarantine();
});
}
const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0));
const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0));
// ---- Library scan ----
const scanStatusStore = $derived(createScanStatusQuery());
@@ -263,84 +119,54 @@
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
<div class="space-y-8">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2>
</header>
<!-- Top stats -->
<div class="grid gap-4 sm:grid-cols-3">
<a
href="/admin/requests"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Pending requests</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-requests">{tile(requests, pendingCount)}</div>
</a>
<a
href="/admin/quarantine"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Quarantined tracks</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-quarantine">{tile(quarantine, quarantineCount)}</div>
</a>
<a
href="/admin/integrations"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Lidarr</div>
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
{config.isError ? '—' : lidarrConnected ? 'Connected' : 'Unset'}
</div>
</a>
</div>
<!-- Pending requests preview -->
<section class="space-y-3">
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Pending requests</h3>
{#if pendingCount > PREVIEW_LIMIT}
<a href="/admin/requests" class="text-sm text-accent-fg hover:underline">View all {pendingCount} →</a>
{/if}
</header>
{#if requests.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if requests.isError}
<p class="text-sm text-error-fg" data-testid="requests-error">
Couldn't load the requests.
<button type="button" class="underline hover:no-underline" onclick={() => requests.refetch()}>Try again</button>
<!-- The verdict, then what needs you, then the healthy rest packed tight. -->
<section class="space-y-3" data-testid="overview-verdict">
<h2 class="sr-only">What needs you</h2>
{#if summaryQ.isPending}
<p class="text-sm text-text-secondary">Reading the queues…</p>
{:else if summaryQ.isError}
<p class="text-sm text-error-fg" data-testid="summary-error">
Couldn't read the queues, so this page can't say whether anything needs you.
<button type="button" class="underline hover:no-underline" onclick={() => summaryQ.refetch()}>Try again</button>
</p>
{:else if requestPreview.length === 0}
<p class="text-sm text-text-secondary">No pending requests.</p>
{:else}
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
{#each requestPreview as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
<li class="flex items-center gap-3 p-3">
<Icon size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{rowTitle(r)}</div>
<div class="truncate text-xs text-text-secondary">{rowSubtitle(r)}</div>
</div>
<button
type="button"
onclick={() => onApprove(r)}
aria-label={`Approve ${rowTitle(r)}`}
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-3 text-sm text-action-fg hover:opacity-90"
>
<Check size={14} strokeWidth={1.5} /> Approve
</button>
<button
type="button"
onclick={() => onReject(r)}
aria-label={`Reject ${rowTitle(r)}`}
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
>
<X size={14} strokeWidth={1.5} /> Reject
</button>
</li>
{/each}
</ul>
{@const problems = waiting.length + (scan?.error_message ? 1 : 0)}
<p class="text-base {problems ? 'text-text-primary' : 'text-text-secondary'}" data-testid="verdict">
{#if problems === 0}
Nothing needs you.
{:else}
{problems === 1 ? '1 thing needs you.' : `${problems} things need you.`}
{/if}
</p>
{#if problems > 0}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#if scan?.error_message}
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3" data-testid="needs-you">
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
{#if scan.finished_at}
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
{/if}
</li>
{/if}
{#each waiting as d (d.key)}
{@const q = summary?.[d.key]}
<li data-testid="needs-you">
<a href={d.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">{queueLabel(d, countOf(summary, d.key))}</span>
{#if q?.since}
<span class="text-xs text-text-muted">oldest {relativeTime(q.since)}</span>
{/if}
<span class="ml-auto text-sm text-accent-fg">Open</span>
</a>
</li>
{/each}
</ul>
{/if}
{#if clear.length > 0}
<p class="text-xs text-text-muted" data-testid="all-clear">
No {clear.map((d) => d.none).join(', ')}.
</p>
{/if}
{/if}
</section>
@@ -532,86 +358,5 @@
{/if}
</section>
<!-- Quarantine preview -->
<section class="space-y-3">
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Quarantine</h3>
{#if quarantineCount > PREVIEW_LIMIT}
<a href="/admin/quarantine" class="text-sm text-accent-fg hover:underline">View all {quarantineCount} →</a>
{/if}
</header>
{#if quarantine.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if quarantine.isError}
<p class="text-sm text-error-fg" data-testid="quarantine-error">
Couldn't load the quarantine.
<button type="button" class="underline hover:no-underline" onclick={() => quarantine.refetch()}>Try again</button>
</p>
{:else if quarantinePreview.length === 0}
<p class="text-sm text-text-secondary">No quarantined tracks.</p>
{:else}
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
{#each quarantinePreview as row (row.track_id)}
{@const fileKey = confirmKey(row.track_id, 'delete-file')}
{@const lidarrKey = confirmKey(row.track_id, 'delete-lidarr')}
{@const fileArmed = confirmingKey === fileKey}
{@const lidarrArmed = confirmingKey === lidarrKey}
<li class="flex items-center gap-3 p-3">
<Music2 size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{row.track_title}</div>
<div class="truncate text-xs text-text-secondary">
{row.artist_name} · {row.album_title}
· {row.report_count} {row.report_count === 1 ? 'report' : 'reports'}
{#if row.reports[0]}
· {REASON_LABELS[row.reports[0].reason] ?? row.reports[0].reason}
{/if}
</div>
</div>
<button
type="button"
onclick={() => onResolve(row)}
aria-label={`Resolve ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
>
<RotateCcw size={14} strokeWidth={1.5} /> Resolve
</button>
<button
type="button"
onclick={() => onDeleteFile(row)}
aria-label={fileArmed ? `Confirm delete file ${row.track_title}` : `Delete file ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {fileArmed
? 'bg-action-secondary text-action-fg'
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
>
<Trash2 size={14} strokeWidth={1.5} /> {fileArmed ? 'Confirm?' : 'Delete file'}
</button>
<!-- Only for an album Lidarr holds, while Lidarr is connected; a
local-only track has nothing for Lidarr to remove (#5604). -->
{#if lidarrConnected && row.lidarr_album_mbid}
<button
type="button"
onclick={() => onDeleteLidarr(row)}
aria-label={lidarrArmed ? `Confirm delete via Lidarr ${row.track_title}` : `Delete via Lidarr ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {lidarrArmed
? 'bg-action-destructive text-action-fg'
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
>
<Cloud size={14} strokeWidth={1.5} /> {lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
</button>
{/if}
<a
href="/admin/quarantine"
aria-label="Open in quarantine queue"
class="text-text-secondary hover:text-text-primary"
>
<ChevronRight size={16} strokeWidth={1.5} />
</a>
</li>
{/each}
</ul>
{/if}
</section>
</div>
+92 -183
View File
@@ -19,9 +19,7 @@ vi.mock('$app/state', () => ({
vi.mock('#lib/api/admin.js', async () => {
const { readable } = await import('svelte/store');
return {
createAdminRequestsQuery: vi.fn(),
createLidarrConfigQuery: vi.fn(),
createAdminQuarantineQuery: vi.fn(),
createAdminSummaryQuery: vi.fn(),
createScanStatusQuery: vi.fn(() =>
readable({
data: { id: '', started_at: '', finished_at: null, in_flight: false },
@@ -41,11 +39,6 @@ vi.mock('#lib/api/admin.js', async () => {
isPending: false,
isError: false
}),
approveRequest: vi.fn().mockResolvedValue({}),
rejectRequest: vi.fn().mockResolvedValue({}),
resolveQuarantine: vi.fn().mockResolvedValue({}),
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
triggerScan: vi.fn().mockResolvedValue({}),
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
@@ -96,203 +89,90 @@ describe('admin/+layout.ts load gate', () => {
// Importing at module load means the mocks attach to the same handles the
// page binds against.
import {
createAdminRequestsQuery,
createLidarrConfigQuery,
createAdminQuarantineQuery,
createAdminSummaryQuery,
createScanStatusQuery,
approveRequest,
rejectRequest,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr,
researchMissingArt
} from '#lib/api/admin.js';
import type { AdminSummary } from '#lib/api/types.js';
import OverviewPage from './+page.svelte';
import AdminLayout from './+layout.svelte';
import { createRawSnippet } from 'svelte';
function setupOverview(opts: {
requests?: unknown[];
quarantine?: unknown[];
lidarr?: { enabled: boolean };
} = {}) {
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.requests ?? [] })
);
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.quarantine ?? [] })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.lidarr ?? { enabled: false } })
const DAY = 24 * 3_600_000;
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
const empty = { count: 0, since: null };
function summary(over: Partial<AdminSummary> = {}): AdminSummary {
return {
requests: empty,
quarantined: empty,
playback_errors: empty,
duplicates: empty,
suspect: empty,
missing: empty,
...over
};
}
function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn()) {
(createAdminSummaryQuery as ReturnType<typeof vi.fn>).mockReturnValue(
data
? mockQuery({ data, refetch })
: mockQuery({ isError: true, error: new Error('boom'), refetch })
);
}
describe('admin Overview page', () => {
test('renders top stat cards (requests, quarantine, lidarr)', async () => {
setupOverview({
requests: [{ id: '1' }, { id: '2' }],
quarantine: [{ track_id: 't1', report_count: 2, reports: [] }],
lidarr: { enabled: true }
});
render(OverviewPage);
// "Pending requests" appears as both stat-card label and section
// header — assert via the link role to disambiguate to the stat card.
const reqLink = screen.getByRole('link', { name: /pending requests/i });
expect(reqLink).toHaveTextContent('2');
const quarLink = screen.getByRole('link', { name: /quarantined tracks/i });
expect(quarLink).toHaveTextContent('1');
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
test('shows empty-state copy when both queues are empty', async () => {
setupOverview({
requests: [],
quarantine: [],
lidarr: { enabled: false }
});
render(OverviewPage);
expect(screen.getByText(/no pending requests/i)).toBeInTheDocument();
expect(screen.getByText(/no quarantined tracks/i)).toBeInTheDocument();
expect(screen.getByText(/unset/i)).toBeInTheDocument();
});
test('top stat cards link to their sub-pages', async () => {
test('with every queue empty, the verdict says nothing needs you, and names what is clear', () => {
setupOverview();
render(OverviewPage);
expect(screen.getByRole('link', { name: /pending requests/i }))
.toHaveAttribute('href', '/admin/requests');
expect(screen.getByRole('link', { name: /quarantined tracks/i }))
.toHaveAttribute('href', '/admin/quarantine');
expect(screen.getByRole('link', { name: /^lidarr/i }))
.toHaveAttribute('href', '/admin/integrations');
expect(screen.getByTestId('verdict').textContent).toMatch(/nothing needs you/i);
expect(screen.queryAllByTestId('needs-you')).toHaveLength(0);
expect(screen.getByTestId('all-clear').textContent).toMatch(/no requests, reports, playback errors, duplicates, suspect sources, missing files/i);
});
test('clicking Approve on a request row calls approveRequest', async () => {
setupOverview({
requests: [
{ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
]
});
render(OverviewPage);
await fireEvent.click(screen.getByRole('button', { name: /approve boards of canada/i }));
expect(approveRequest).toHaveBeenCalledWith('r1');
});
test('clicking Reject on a request row calls rejectRequest', async () => {
setupOverview({
requests: [
{ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
]
});
render(OverviewPage);
await fireEvent.click(screen.getByRole('button', { name: /reject boards of canada/i }));
expect(rejectRequest).toHaveBeenCalledWith('r1');
});
test('Resolve fires immediately on a quarantine row', async () => {
setupOverview({
quarantine: [
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', report_count: 1, reports: [] }
]
});
render(OverviewPage);
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
expect(resolveQuarantine).toHaveBeenCalledWith('t1');
});
test('Delete file requires two clicks (inline confirm)', async () => {
setupOverview({
quarantine: [
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', report_count: 1, reports: [] }
]
});
render(OverviewPage);
// First click: arms the confirm; no API call yet.
await fireEvent.click(screen.getByRole('button', { name: /^delete file roygbiv$/i }));
expect(deleteQuarantineFile).not.toHaveBeenCalled();
// Second click on the now-armed button fires the action.
await fireEvent.click(
screen.getByRole('button', { name: /confirm delete file roygbiv/i })
);
expect(deleteQuarantineFile).toHaveBeenCalledWith('t1');
});
test('Delete via Lidarr requires two clicks (inline confirm)', async () => {
setupOverview({
quarantine: [
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] }
],
lidarr: { enabled: true }
});
render(OverviewPage);
await fireEvent.click(
screen.getByRole('button', { name: /^delete via lidarr roygbiv$/i })
);
expect(deleteQuarantineViaLidarr).not.toHaveBeenCalled();
await fireEvent.click(
screen.getByRole('button', { name: /confirm delete via lidarr roygbiv/i })
);
expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t1');
});
test('Delete via Lidarr is offered only for an album Lidarr holds, while Lidarr is connected', async () => {
const local = { track_id: 't1', track_title: 'Dial Tone', artist_name: 'Annadale',
album_title: 'Dial Tone', lidarr_album_mbid: null, report_count: 1, reports: [] };
const held = { track_id: 't2', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] };
setupOverview({ quarantine: [local, held], lidarr: { enabled: false } });
render(OverviewPage);
expect(screen.queryByRole('button', { name: /delete via lidarr/i })).toBeNull();
cleanup();
setupOverview({ quarantine: [local, held], lidarr: { enabled: true } });
render(OverviewPage);
expect(screen.queryByRole('button', { name: /delete via lidarr dial tone/i })).toBeNull();
expect(screen.getByRole('button', { name: /delete via lidarr roygbiv/i })).toBeInTheDocument();
});
test('a queue that fails to load says so, with Try again, and its tile shows a dash, not 0', async () => {
const refetch = vi.fn();
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: new Error('boom'), refetch })
);
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: new Error('boom') })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { enabled: false } })
);
render(OverviewPage);
expect(screen.queryByText(/no pending requests/i)).toBeNull();
expect(screen.queryByText(/no quarantined tracks/i)).toBeNull();
expect(screen.getByTestId('tile-requests').textContent).toBe('—');
expect(screen.getByTestId('tile-quarantine').textContent).toBe('—');
expect(screen.getByTestId('requests-error').textContent).toMatch(/couldn't load the requests/i);
await fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(refetch).toHaveBeenCalled();
});
test('"View all" link surfaces only when count exceeds the preview limit', async () => {
// 6 requests > limit of 5; quarantine empty.
const six = Array.from({ length: 6 }, (_, i) => ({
id: `r${i}`,
kind: 'artist' as const,
artist_name: `Artist ${i}`,
status: 'pending' as const
test('each queue that waits on you is listed with how long its oldest has waited, and opens its page', () => {
setupOverview(summary({
requests: { count: 2, since: ago(3) },
missing: { count: 1, since: ago(10) }
}));
setupOverview({ requests: six });
render(OverviewPage);
expect(screen.getByRole('link', { name: /view all 6/i })).toHaveAttribute(
'href', '/admin/requests'
expect(screen.getByTestId('verdict').textContent).toMatch(/2 things need you/);
const rows = screen.getAllByTestId('needs-you').map((r) => r.textContent?.replace(/\s+/g, ' ').trim());
expect(rows).toEqual(['2 requests to decide oldest 3d ago Open', '1 missing file oldest 10d ago Open']);
expect(screen.getByRole('link', { name: /2 requests to decide/i }).getAttribute('href')).toBe('/admin/requests');
expect(screen.getByTestId('all-clear').textContent).not.toMatch(/requests/);
});
test('a failed scan needs you too', async () => {
const { readable } = await import('svelte/store');
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
readable({
data: { id: 's1', started_at: ago(1), finished_at: ago(1), in_flight: false, error_message: 'library root not mounted' },
isPending: false,
isError: false
})
);
setupOverview();
render(OverviewPage);
expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/);
expect(screen.getByText(/the last library scan failed: library root not mounted/i)).toBeInTheDocument();
});
test('when the queues fail to load it says so, with Try again, rather than reading as all-clear', async () => {
const refetch = vi.fn();
setupOverview(null, refetch);
render(OverviewPage);
expect(screen.queryByTestId('verdict')).toBeNull();
expect(screen.getByTestId('summary-error').textContent).toMatch(/can't say whether anything needs you/i);
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(refetch).toHaveBeenCalled();
});
test('renders Re-search missing art button and invokes on click', async () => {
setupOverview();
render(OverviewPage);
const btn = screen.getByRole('button', { name: /re-search missing art/i });
expect(btn).toBeInTheDocument();
await fireEvent.click(btn);
expect(researchMissingArt).toHaveBeenCalled();
});
@@ -318,3 +198,32 @@ describe('admin Overview page', () => {
expect(await screen.findByText('Processing')).toBeInTheDocument();
});
});
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) } }));
const children = createRawSnippet(() => ({ render: () => '<p data-testid="admin-body">body</p>' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(AdminLayout, { children } as any);
const nav = screen.getByRole('navigation', { name: /admin sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [
a.getAttribute('href'),
a.textContent?.replace(/\s+/g, ' ').trim()
]);
expect(links).toEqual([
['/admin', 'Overview'],
['/admin/requests', 'Requests 2'],
['/admin/quarantine', 'Quarantine'],
['/admin/playback-errors', 'Playback errors'],
['/admin/duplicates', 'Duplicates 5'],
['/admin/suspect-sources', 'Suspect sources'],
['/admin/missing-files', 'Missing files'],
['/admin/integrations', 'Integrations'],
['/admin/users', 'Users'],
['/admin/diagnostics', 'Diagnostics'],
['/admin/tuning', 'Tuning']
]);
expect(screen.getByRole('link', { name: /^overview/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByTestId('admin-body')).toBeInTheDocument();
});
});