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;
|
||||
|
||||
Reference in New Issue
Block a user