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;