feat(analysis): admin UI — reader context-menu action + dashboard section

Surfaces the scoped re-enqueue and per-page force-analyze in the UI:

- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
  analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
  (canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
  endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
  (whole library / one manga / one chapter), id input, and an
  "include already-analyzed" toggle (default off), with busy/notice/error.

Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 19:57:03 +02:00
parent 9607488278
commit 8b7ea2e1b2
11 changed files with 551 additions and 8 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.72.0"
version = "0.73.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.72.0"
version = "0.73.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -0,0 +1,124 @@
import { test, expect, type Page } from '@playwright/test';
// E2E for the admin Analysis section: scoped re-enqueue (all / manga /
// chapter) with the include-already-analyzed toggle. Fully mocked.
const DESKTOP = { width: 1280, height: 720 } as const;
const adminUser = {
id: 'u11111111-1111-1111-1111-111111111111',
username: 'admin',
created_at: '2026-01-01T00:00:00Z',
is_admin: true
};
const systemStats = {
disk: null,
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
cpu: { percent_used: 0 },
alerts: []
};
type Captured = { body: Record<string, unknown> | null };
async function mockAdmin(page: Page, captured: Captured) {
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: adminUser })
})
);
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route('**/api/v1/admin/system', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(systemStats)
})
);
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
captured.body = JSON.parse(route.request().postData() ?? '{}');
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enqueued: 12 })
});
});
}
test.describe('/admin/analysis', () => {
test('default whole-library re-enqueue posts only_unanalyzed=true', async ({
page
}) => {
const captured: Captured = { body: null };
await mockAdmin(page, captured);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await expect(page.getByTestId('admin-analysis-form')).toBeVisible();
await page.getByTestId('admin-analysis-submit').click();
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
'Enqueued 12 pages'
);
expect(captured.body).toEqual({ only_unanalyzed: true });
});
test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', async ({
page
}) => {
const captured: Captured = { body: null };
await mockAdmin(page, captured);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await page.getByTestId('admin-analysis-scope-manga').check();
await page
.getByTestId('admin-analysis-manga-id')
.fill('a9999999-9999-9999-9999-999999999999');
await page.getByTestId('admin-analysis-include-analyzed').check();
await page.getByTestId('admin-analysis-submit').click();
await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
expect(captured.body).toEqual({
only_unanalyzed: false,
manga_id: 'a9999999-9999-9999-9999-999999999999'
});
});
test('chapter scope requires an id before posting', async ({ page }) => {
const captured: Captured = { body: null };
await mockAdmin(page, captured);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await page.getByTestId('admin-analysis-scope-chapter').check();
await page.getByTestId('admin-analysis-submit').click();
await expect(page.getByTestId('admin-analysis-error')).toContainText(
'Enter a chapter id'
);
expect(captured.body).toBeNull();
});
});

View File

@@ -73,7 +73,12 @@ const collectionFixture = {
*/
async function mockReader(
page: Page,
state: { collectionsContainingPage: string[]; tagsOnPage: string[] }
state: {
collectionsContainingPage: string[];
tagsOnPage: string[];
admin?: boolean;
analyzeCalls?: number;
}
) {
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
@@ -86,9 +91,20 @@ async function mockReader(
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: userFixture })
body: JSON.stringify({
user: { ...userFixture, is_admin: state.admin ?? false }
})
})
);
// Admin force-analyze endpoint — counts invocations for assertions.
await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => {
state.analyzeCalls = (state.analyzeCalls ?? 0) + 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enqueued: true })
});
});
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 200,
@@ -266,6 +282,37 @@ test.describe('page context menu (desktop right-click)', () => {
);
});
test('non-admin does not see the Queue for analysis action', async ({ page }) => {
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
await expect(page.getByTestId('page-context-menu')).toBeVisible();
await expect(page.getByTestId('page-context-analyze')).toBeHidden();
});
test('admin can queue the page for analysis from the menu', async ({ page }) => {
const state = {
collectionsContainingPage: [],
tagsOnPage: [],
admin: true,
analyzeCalls: 0
};
await mockReader(page, state);
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await page.getByTestId('reader-page').click({ button: 'right' });
const item = page.getByTestId('page-context-analyze');
await expect(item).toBeVisible();
await expect(item).toContainText('Queue for analysis');
await item.click();
await expect(item).toContainText('Queued for analysis');
expect(state.analyzeCalls).toBe(1);
});
test('Shift+right-click falls through to the browser native menu', async ({
page
}) => {

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.72.0",
"version": "0.73.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -26,7 +26,9 @@ import {
listDeadJobs,
requeueDeadJobs,
listActiveJobs,
listMissingCovers
listMissingCovers,
reenqueueAnalysis,
analyzePage
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -494,4 +496,42 @@ describe('admin crawler api client', () => {
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
});
it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 }));
const r = await reenqueueAnalysis();
expect(r.enqueued).toBe(42);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/);
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: true
});
});
it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 }));
await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false });
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: false,
manga_id: 'm1'
});
});
it('reenqueueAnalysis scopes to a chapter', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 }));
await reenqueueAnalysis({ chapterId: 'c1' });
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: true,
chapter_id: 'c1'
});
});
it('analyzePage posts to the force-analyze endpoint', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: true }));
const r = await analyzePage('p1');
expect(r.enqueued).toBe(true);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
});
});

View File

@@ -403,3 +403,41 @@ export async function listMissingCovers(
init
);
}
// ---- AI content analysis ---------------------------------------------------
/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId`
* are mutually exclusive; omit both to target the whole library. */
export type ReenqueueAnalysisOptions = {
/** Skip pages that already have a completed analysis (default true).
* When false, in-scope pages are force re-analyzed. */
onlyUnanalyzed?: boolean;
mangaId?: string;
chapterId?: string;
};
/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's
* pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED
* on the backend (503 otherwise). */
export async function reenqueueAnalysis(
opts: ReenqueueAnalysisOptions = {}
): Promise<{ enqueued: number }> {
const body: Record<string, unknown> = {
only_unanalyzed: opts.onlyUnanalyzed ?? true
};
if (opts.mangaId) body.manga_id = opts.mangaId;
if (opts.chapterId) body.chapter_id = opts.chapterId;
return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
/** Force re-analysis of a single page (used by the reader context menu). */
export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> {
return request<{ enqueued: boolean }>(
`/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`,
{ method: 'POST' }
);
}

View File

@@ -4,6 +4,7 @@
import Tag from '@lucide/svelte/icons/tag';
import Download from '@lucide/svelte/icons/download';
import Link2 from '@lucide/svelte/icons/link-2';
import Sparkles from '@lucide/svelte/icons/sparkles';
/**
* Floating context menu anchored at `(anchor.x, anchor.y)` in
@@ -25,7 +26,10 @@
onSaveImage,
onCopyLink,
collectionsCount,
tags
tags,
canAnalyze = false,
onAnalyzePage,
analyzeState = 'idle'
}: {
open: boolean;
anchor: { x: number; y: number };
@@ -41,6 +45,12 @@
collectionsCount: number | null;
/** May be empty. */
tags: string[];
/** Admin-only: show the "Queue for analysis" action. */
canAnalyze?: boolean;
/** Force-enqueue the current page for AI analysis. */
onAnalyzePage?: () => void;
/** Transient state for the analyze action's label. */
analyzeState?: 'idle' | 'busy' | 'done' | 'error';
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
@@ -170,6 +180,24 @@
<Link2 size={14} aria-hidden="true" />
<span>Copy page link</span>
</button>
{#if canAnalyze}
<div class="divider" aria-hidden="true"></div>
<button
type="button"
class="item"
onclick={onAnalyzePage}
disabled={analyzeState === 'busy'}
data-testid="page-context-analyze"
>
<Sparkles size={14} aria-hidden="true" />
<span>
{#if analyzeState === 'busy'}Queueing…
{:else if analyzeState === 'done'}Queued for analysis ✓
{:else if analyzeState === 'error'}Queue failed — retry
{:else}Queue for analysis{/if}
</span>
</button>
{/if}
<div class="divider" aria-hidden="true"></div>
<p class="hint" data-testid="page-context-collections-line">
{collectionsLine}

View File

@@ -7,6 +7,7 @@
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/mangas', label: 'Mangas' },
{ href: '/admin/crawler', label: 'Crawler' },
{ href: '/admin/analysis', label: 'Analysis' },
{ href: '/admin/system', label: 'System' }
];
</script>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { ApiError } from '$lib/api/client';
import { reenqueueAnalysis } from '$lib/api/admin';
type Scope = 'all' | 'manga' | 'chapter';
let scope = $state<Scope>('all');
let mangaId = $state('');
let chapterId = $state('');
// Default: skip pages that already have a completed analysis.
let includeAnalyzed = $state(false);
let busy = $state(false);
let error = $state<string | null>(null);
let notice = $state<string | null>(null);
const scopeOptions: { value: Scope; label: string; hint: string }[] = [
{
value: 'all',
label: 'Whole library',
hint: 'Every page of every chapter of every manga.'
},
{
value: 'manga',
label: 'One manga',
hint: 'All pages across the chosen mangas chapters.'
},
{ value: 'chapter', label: 'One chapter', hint: 'All pages of the chosen chapter.' }
];
async function submit(e: SubmitEvent) {
e.preventDefault();
if (busy) return;
error = null;
notice = null;
const opts: {
onlyUnanalyzed: boolean;
mangaId?: string;
chapterId?: string;
} = { onlyUnanalyzed: !includeAnalyzed };
if (scope === 'manga') {
if (!mangaId.trim()) {
error = 'Enter a manga id.';
return;
}
opts.mangaId = mangaId.trim();
} else if (scope === 'chapter') {
if (!chapterId.trim()) {
error = 'Enter a chapter id.';
return;
}
opts.chapterId = chapterId.trim();
}
busy = true;
try {
const r = await reenqueueAnalysis(opts);
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis.`;
} catch (err) {
if (err instanceof ApiError) {
error =
err.status === 503
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
: err.message;
} else {
error = 'Failed to enqueue analysis.';
}
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>Mangalord | Admin — Analysis</title>
</svelte:head>
<h1 class="heading">Content analysis</h1>
<p class="lede">
Queue page images for the AI analysis worker (OCR, auto-tags, scene
description, NSFW moderation). Pick a scope and run it.
</p>
<form class="panel" onsubmit={submit} data-testid="admin-analysis-form">
<fieldset class="scopes">
<legend>Scope</legend>
{#each scopeOptions as o (o.value)}
<label class="scope-opt" class:active={scope === o.value}>
<input
type="radio"
name="scope"
value={o.value}
checked={scope === o.value}
onchange={() => (scope = o.value)}
data-testid={`admin-analysis-scope-${o.value}`}
/>
<span class="scope-label">{o.label}</span>
<span class="scope-hint">{o.hint}</span>
</label>
{/each}
</fieldset>
{#if scope === 'manga'}
<label class="field">
<span>Manga id</span>
<input
type="text"
bind:value={mangaId}
placeholder="uuid"
data-testid="admin-analysis-manga-id"
/>
</label>
{:else if scope === 'chapter'}
<label class="field">
<span>Chapter id</span>
<input
type="text"
bind:value={chapterId}
placeholder="uuid"
data-testid="admin-analysis-chapter-id"
/>
</label>
{/if}
<label class="checkbox">
<input
type="checkbox"
bind:checked={includeAnalyzed}
data-testid="admin-analysis-include-analyzed"
/>
<span>
Include already-analyzed pages
<span class="muted">
— re-runs analysis on pages that already have results
(default off: only un-analyzed pages are queued).
</span>
</span>
</label>
<div class="actions">
<button type="submit" disabled={busy} data-testid="admin-analysis-submit">
{busy ? 'Queueing…' : 'Queue for analysis'}
</button>
</div>
{#if notice}
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
{/if}
{#if error}
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
</form>
<style>
.heading {
margin-bottom: var(--space-2);
}
.lede {
color: var(--text-muted);
margin-bottom: var(--space-4);
max-width: 42rem;
}
.panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 42rem;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg, 12px);
background: var(--surface);
}
fieldset.scopes {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
legend {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-1);
}
.scope-opt {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--space-2);
align-items: baseline;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
}
.scope-opt.active {
border-color: var(--primary);
background: var(--primary-soft-bg);
}
.scope-opt input {
grid-row: 1 / span 2;
align-self: center;
}
.scope-label {
font-weight: var(--weight-semibold);
}
.scope-hint {
grid-column: 2;
color: var(--text-muted);
font-size: var(--font-sm);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field span {
color: var(--text-muted);
font-size: var(--font-sm);
}
.field input {
max-width: 26rem;
}
.checkbox {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2);
align-items: start;
font-size: var(--font-sm);
}
.checkbox .muted {
color: var(--text-muted);
}
.actions {
display: flex;
gap: var(--space-2);
}
.notice {
color: var(--success, #2e7d32);
}
.error {
color: var(--danger);
}
</style>

View File

@@ -7,7 +7,7 @@
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { chapterLabel } from '$lib/api/chapters';
import { resyncChapter } from '$lib/api/admin';
import { resyncChapter, analyzePage } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import Sheet from '$lib/components/Sheet.svelte';
@@ -118,6 +118,8 @@
let actionSheetOpen = $state(false);
let collectionsModalOpen = $state(false);
let tagsModalOpen = $state(false);
// Admin-only "Queue for analysis" action feedback, reset per page.
let analyzeState = $state<'idle' | 'busy' | 'done' | 'error'>('idle');
// Monotonically-increasing token so a slow loadPageSummary for
// page A can't clobber a fresh load for page B. The user right-
@@ -148,13 +150,27 @@
activePageId = pageId;
contextMenuAnchor = anchor;
contextMenuOpen = true;
analyzeState = 'idle';
void loadPageSummary(pageId);
}
async function handleAnalyzePage() {
const pageId = activePageId;
if (!pageId || !session.user?.is_admin || analyzeState === 'busy') return;
analyzeState = 'busy';
try {
await analyzePage(pageId);
analyzeState = 'done';
} catch {
analyzeState = 'error';
}
}
function openActionSheet(pageId: string) {
if (!session.user) return;
activePageId = pageId;
actionSheetOpen = true;
analyzeState = 'idle';
void loadPageSummary(pageId);
}
@@ -1454,6 +1470,9 @@
onCopyLink={handleCopyLink}
collectionsCount={activePageCollectionCount}
tags={activePageTags}
canAnalyze={!!session.user?.is_admin}
onAnalyzePage={handleAnalyzePage}
{analyzeState}
/>
<!-- Mobile action sheet: same two actions, larger touch targets. -->