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