feat(analysis): admin coverage overview + per-page result inspector UI
Reworks /admin/analysis from a blind enqueue form into a coverage browser: - Loads a paginated overview of mangas with a CoverageBadge (Full/Partial/None, analyzed/total) per row; debounced search filters it. - Drill manga → chapters (coverage badge each) → page grid (chips colored done/queued/failed/none) with a legend. - Click a page chip → Modal showing the full result: status, model, time, NSFW + content-warning chips, scene description, tags, and OCR lines (labeled by kind). Unanalyzed/failed pages get a "Queue this page" action (force re-analyze). - Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed toggle; enqueue refreshes the open chapter's grid so queued pages show. API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages / PageDetail + types. New CoverageBadge component. Tests: vitest for the four clients; Playwright for overview+badges+queue-all, drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,11 @@ import {
|
||||
listActiveJobs,
|
||||
listMissingCovers,
|
||||
reenqueueAnalysis,
|
||||
analyzePage
|
||||
analyzePage,
|
||||
getAnalysisMangaCoverage,
|
||||
getAnalysisChapterCoverage,
|
||||
getAnalysisChapterPages,
|
||||
getAnalysisPageDetail
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -534,4 +538,46 @@ describe('admin crawler api client', () => {
|
||||
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
|
||||
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('getAnalysisMangaCoverage passes search + pagination', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
|
||||
);
|
||||
await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/admin/analysis/mangas?');
|
||||
expect(url).toContain('search=ber');
|
||||
expect(url).toContain('limit=25');
|
||||
expect(url).toContain('offset=50');
|
||||
});
|
||||
|
||||
it('getAnalysisChapterCoverage unwraps items', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
|
||||
);
|
||||
const items = await getAnalysisChapterCoverage('m1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/);
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getAnalysisChapterPages unwraps items', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] })
|
||||
);
|
||||
const items = await getAnalysisChapterPages('c1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/);
|
||||
expect(items[0].status).toBe('done');
|
||||
});
|
||||
|
||||
it('getAnalysisPageDetail hits the page endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] })
|
||||
);
|
||||
const d = await getAnalysisPageDetail('p1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
|
||||
expect(d.status).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { request, apiUrl, type Page } from './client';
|
||||
import type { User } from './auth';
|
||||
import type { MangaDetail } from './mangas';
|
||||
import type { Chapter } from './chapters';
|
||||
import type { ContentWarning } from './page_tags';
|
||||
|
||||
// ---- users -----------------------------------------------------------------
|
||||
|
||||
@@ -441,3 +442,93 @@ export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
// ---- AI content analysis: coverage & inspection ----------------------------
|
||||
|
||||
export type MangaCoverage = {
|
||||
manga_id: string;
|
||||
title: string;
|
||||
total_pages: number;
|
||||
analyzed_pages: number;
|
||||
};
|
||||
|
||||
export type MangaCoveragePage = {
|
||||
items: MangaCoverage[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type ChapterCoverage = {
|
||||
chapter_id: string;
|
||||
number: number;
|
||||
title: string | null;
|
||||
total_pages: number;
|
||||
analyzed_pages: number;
|
||||
};
|
||||
|
||||
/** Per-page status in a chapter grid. */
|
||||
export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none';
|
||||
|
||||
export type PageStatusItem = {
|
||||
page_id: string;
|
||||
page_number: number;
|
||||
status: PageAnalysisStatus;
|
||||
};
|
||||
|
||||
export type OcrLine = { kind: string; text: string };
|
||||
|
||||
export type PageAnalysisDetail = {
|
||||
page_id: string;
|
||||
page_number: number;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
/** `done` | `failed` | `none` (no analysis row yet). */
|
||||
status: 'done' | 'failed' | 'none';
|
||||
is_nsfw: boolean;
|
||||
scene_description: string | null;
|
||||
model: string | null;
|
||||
error: string | null;
|
||||
analyzed_at: string | null;
|
||||
ocr: OcrLine[];
|
||||
tags: string[];
|
||||
content_warnings: ContentWarning[];
|
||||
};
|
||||
|
||||
/** Paginated per-manga analysis coverage (admin overview). */
|
||||
export async function getAnalysisMangaCoverage(
|
||||
opts: { search?: string; limit?: number; offset?: number } = {}
|
||||
): Promise<MangaCoveragePage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<MangaCoveragePage>(
|
||||
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAnalysisChapterCoverage(
|
||||
mangaId: string
|
||||
): Promise<ChapterCoverage[]> {
|
||||
const r = await request<{ items: ChapterCoverage[] }>(
|
||||
`/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
|
||||
);
|
||||
return r.items;
|
||||
}
|
||||
|
||||
export async function getAnalysisChapterPages(
|
||||
chapterId: string
|
||||
): Promise<PageStatusItem[]> {
|
||||
const r = await request<{ items: PageStatusItem[] }>(
|
||||
`/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
|
||||
);
|
||||
return r.items;
|
||||
}
|
||||
|
||||
export async function getAnalysisPageDetail(
|
||||
pageId: string
|
||||
): Promise<PageAnalysisDetail> {
|
||||
return request<PageAnalysisDetail>(
|
||||
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user