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
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:
@@ -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}`;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user