feat(crawler): live cover + chapter-content observability with realtime page counts
Extends the live dashboard so an operator can see exactly what's being fetched, in realtime: - Chapters being crawled now are tracked in the status as `active_chapters` (manga title · ch.N) with a live page counter that climbs per stored page (set_chapter_pages, pushed via the existing watch→SSE). The dispatcher registers each via an RAII ChapterGuard (sync Mutex) that removes the entry on completion, panic, or timeout-drop — replacing the old per-worker slot model. - Covers: status now carries the cover being fetched now (`current_cover`, set around download_and_store_cover in both the metadata pass and backfill) and a `covers_queued` backlog count; CoverBackfill phase gains index/total. - Two paginated backlog endpoints (fetched on demand, auto-refreshed when the live counts change): GET /admin/crawler/active-jobs (which chapters of which mangas are queued/running) and GET /admin/crawler/covers (mangas missing a cover). repo: list_active_jobs, list_missing_cover_mangas, count_missing_covers. - dispatch_target now also returns manga title + chapter number. Frontend: the crawler page replaces the Workers table with an Active-chapters table (live page bars), adds a current-cover line + covers-queued figure, and two backlog sections (Queued chapters / Queued covers) with search + Pager, auto-refetched via $effect on the live counts. Tests: status guard/page + cover unit tests; repo list/count tests; endpoint tests; frontend api tests. Version 0.53.1 -> 0.54.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.53.1",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
updateCrawlerSession,
|
||||
clearCrawlerSessionExpired,
|
||||
listDeadJobs,
|
||||
requeueDeadJobs
|
||||
requeueDeadJobs,
|
||||
listActiveJobs,
|
||||
listMissingCovers
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -350,7 +352,19 @@ describe('admin crawler api client', () => {
|
||||
const statusFixture = {
|
||||
daemon: 'running',
|
||||
phase: { state: 'fetching_metadata', index: 3, total: 10, title: 'One Piece' },
|
||||
workers: [{ state: 'idle' }],
|
||||
worker_count: 2,
|
||||
active_chapters: [
|
||||
{
|
||||
manga_id: 'm-1',
|
||||
manga_title: 'Bleach',
|
||||
chapter_id: 'c-1',
|
||||
chapter_number: 12,
|
||||
pages_done: 4,
|
||||
pages_total: 20
|
||||
}
|
||||
],
|
||||
current_cover: { manga_id: 'm-2', manga_title: 'Naruto' },
|
||||
covers_queued: 7,
|
||||
last_pass: { at: null, discovered: 0, upserted: 0, covers_fetched: 0, mangas_failed: 0 },
|
||||
session: { expired: false, configured: true },
|
||||
browser: 'healthy',
|
||||
@@ -361,15 +375,38 @@ describe('admin crawler api client', () => {
|
||||
expect(crawlerStatusStreamUrl()).toMatch(/\/v1\/admin\/crawler\/stream$/);
|
||||
});
|
||||
|
||||
it('getCrawlerStatus GETs /v1/admin/crawler', async () => {
|
||||
it('getCrawlerStatus GETs /v1/admin/crawler with live chapter/cover fields', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(statusFixture));
|
||||
const s = await getCrawlerStatus();
|
||||
expect(s.queue.dead).toBe(4);
|
||||
expect(s.phase?.state).toBe('fetching_metadata');
|
||||
expect(s.active_chapters[0].pages_done).toBe(4);
|
||||
expect(s.active_chapters[0].pages_total).toBe(20);
|
||||
expect(s.current_cover?.manga_title).toBe('Naruto');
|
||||
expect(s.covers_queued).toBe(7);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/crawler$/);
|
||||
});
|
||||
|
||||
it('listActiveJobs GETs /v1/admin/crawler/active-jobs with search', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 20, offset: 0, total: 0 } })
|
||||
);
|
||||
await listActiveJobs({ search: 'bleach' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/crawler\/active-jobs\?/);
|
||||
expect(url).toContain('search=bleach');
|
||||
});
|
||||
|
||||
it('listMissingCovers GETs /v1/admin/crawler/covers', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [{ manga_id: 'm-1', manga_title: 'X' }], page: { limit: 20, offset: 0, total: 1 } })
|
||||
);
|
||||
const r = await listMissingCovers();
|
||||
expect(r.items[0].manga_title).toBe('X');
|
||||
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/);
|
||||
});
|
||||
|
||||
it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
|
||||
const r = await runCrawlerPass();
|
||||
|
||||
@@ -222,9 +222,17 @@ export type CrawlerPhase =
|
||||
| { state: 'idle'; next_fire: string | null }
|
||||
| { state: 'walking_list' }
|
||||
| { state: 'fetching_metadata'; index: number; total: number | null; title: string }
|
||||
| { state: 'cover_backfill' };
|
||||
| { state: 'cover_backfill'; index: number; total: number };
|
||||
|
||||
export type CrawlerWorker = { state: 'idle' } | { state: 'working'; chapter_id: string };
|
||||
/** A chapter being crawled right now, with a live page count. */
|
||||
export type ActiveChapter = {
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
chapter_id: string;
|
||||
chapter_number: number;
|
||||
pages_done: number;
|
||||
pages_total: number | null;
|
||||
};
|
||||
|
||||
export type CrawlerLastPass = {
|
||||
at: string | null;
|
||||
@@ -237,7 +245,10 @@ export type CrawlerLastPass = {
|
||||
export type CrawlerStatus = {
|
||||
daemon: 'running' | 'disabled';
|
||||
phase: CrawlerPhase | null;
|
||||
workers: CrawlerWorker[];
|
||||
worker_count: number;
|
||||
active_chapters: ActiveChapter[];
|
||||
current_cover: { manga_id: string; manga_title: string } | null;
|
||||
covers_queued: number;
|
||||
last_pass: CrawlerLastPass;
|
||||
session: { expired: boolean; configured: boolean };
|
||||
browser: 'healthy' | 'draining' | 'restarting' | 'down';
|
||||
@@ -324,3 +335,51 @@ export async function requeueDeadJobs(scope: RequeueScope): Promise<{ requeued:
|
||||
body: JSON.stringify(scope)
|
||||
});
|
||||
}
|
||||
|
||||
/** A queued/running chapter-content job (which chapters are queued). */
|
||||
export type ActiveJob = {
|
||||
id: string;
|
||||
chapter_id: string | null;
|
||||
manga_id: string | null;
|
||||
manga_title: string | null;
|
||||
chapter_number: number | null;
|
||||
state: 'pending' | 'running';
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ActiveJobsPage = { items: ActiveJob[]; page: Page };
|
||||
|
||||
/** GET /v1/admin/crawler/active-jobs — which chapters of which mangas are
|
||||
* queued or running now. */
|
||||
export async function listActiveJobs(opts?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ActiveJobsPage> {
|
||||
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<ActiveJobsPage>(`/v1/admin/crawler/active-jobs${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
/** A manga queued for a cover fetch (no cover yet + a live source). */
|
||||
export type MissingCover = { manga_id: string; manga_title: string };
|
||||
export type MissingCoversPage = { items: MissingCover[]; page: Page };
|
||||
|
||||
/** GET /v1/admin/crawler/covers — which manga covers are queued. */
|
||||
export async function listMissingCovers(opts?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MissingCoversPage> {
|
||||
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<MissingCoversPage>(`/v1/admin/crawler/covers${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
clearCrawlerSessionExpired,
|
||||
listDeadJobs,
|
||||
requeueDeadJobs,
|
||||
listActiveJobs,
|
||||
listMissingCovers,
|
||||
type CrawlerStatus,
|
||||
type CrawlerPhase,
|
||||
type DeadJob,
|
||||
type ActiveJob,
|
||||
type MissingCover,
|
||||
type RequeueScope
|
||||
} from '$lib/api/admin';
|
||||
|
||||
@@ -31,6 +35,20 @@
|
||||
let deadPage = $state(1);
|
||||
const DEAD_LIMIT = 20;
|
||||
|
||||
// Queued chapters (pending/running)
|
||||
let activeJobs: ActiveJob[] = $state([]);
|
||||
let activeTotal = $state(0);
|
||||
let activeSearch = $state('');
|
||||
let activePage = $state(1);
|
||||
const ACTIVE_LIMIT = 20;
|
||||
|
||||
// Queued covers (mangas missing a cover)
|
||||
let covers: MissingCover[] = $state([]);
|
||||
let coversTotal = $state(0);
|
||||
let coversSearch = $state('');
|
||||
let coversPage = $state(1);
|
||||
const COVERS_LIMIT = 20;
|
||||
|
||||
// Modals
|
||||
let sessionModalOpen = $state(false);
|
||||
let restartModalOpen = $state(false);
|
||||
@@ -60,6 +78,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveJobs() {
|
||||
try {
|
||||
const resp = await listActiveJobs({
|
||||
search: activeSearch.trim() || undefined,
|
||||
limit: ACTIVE_LIMIT,
|
||||
offset: (activePage - 1) * ACTIVE_LIMIT
|
||||
});
|
||||
activeJobs = resp.items;
|
||||
activeTotal = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'failed to load queued chapters';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCovers() {
|
||||
try {
|
||||
const resp = await listMissingCovers({
|
||||
search: coversSearch.trim() || undefined,
|
||||
limit: COVERS_LIMIT,
|
||||
offset: (coversPage - 1) * COVERS_LIMIT
|
||||
});
|
||||
covers = resp.items;
|
||||
coversTotal = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'failed to load queued covers';
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh the (fetched, not streamed) backlog lists when the live
|
||||
// status shows the relevant counts moved — keeps the lists feeling live
|
||||
// without pushing big payloads over SSE. `$effect` re-runs when these
|
||||
// tracked values change.
|
||||
let lastQueueKey = $state('');
|
||||
let lastCoversKey = $state(-1);
|
||||
$effect(() => {
|
||||
const k = `${status?.queue.pending ?? 0}:${status?.queue.running ?? 0}`;
|
||||
if (k !== lastQueueKey) {
|
||||
lastQueueKey = k;
|
||||
loadActiveJobs();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const c = status?.covers_queued ?? -1;
|
||||
if (c !== lastCoversKey) {
|
||||
lastCoversKey = c;
|
||||
loadCovers();
|
||||
}
|
||||
});
|
||||
|
||||
// Live updates via Server-Sent Events instead of polling. The
|
||||
// EventSource is opened on mount and closed on destroy, so the
|
||||
// subscription exists only while this page is showing live data.
|
||||
@@ -171,6 +238,23 @@
|
||||
loadDeadJobs();
|
||||
}
|
||||
|
||||
function onSearchActive() {
|
||||
activePage = 1;
|
||||
loadActiveJobs();
|
||||
}
|
||||
function onActivePageChange(p: number) {
|
||||
activePage = p;
|
||||
loadActiveJobs();
|
||||
}
|
||||
function onSearchCovers() {
|
||||
coversPage = 1;
|
||||
loadCovers();
|
||||
}
|
||||
function onCoversPageChange(p: number) {
|
||||
coversPage = p;
|
||||
loadCovers();
|
||||
}
|
||||
|
||||
// ---- display helpers ----
|
||||
function phaseLabel(p: CrawlerPhase | null): string {
|
||||
if (!p) return 'Daemon disabled';
|
||||
@@ -184,7 +268,7 @@
|
||||
case 'fetching_metadata':
|
||||
return `Fetching metadata · ${p.index}/${p.total ?? '?'} · ${p.title}`;
|
||||
case 'cover_backfill':
|
||||
return 'Backfilling covers';
|
||||
return `Backfilling covers · ${p.index + 1}/${p.total}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +299,14 @@
|
||||
}
|
||||
|
||||
const deadTotalPages = $derived(Math.max(1, Math.ceil(deadTotal / DEAD_LIMIT)));
|
||||
const activeTotalPages = $derived(Math.max(1, Math.ceil(activeTotal / ACTIVE_LIMIT)));
|
||||
const coversTotalPages = $derived(Math.max(1, Math.ceil(coversTotal / COVERS_LIMIT)));
|
||||
|
||||
function chapterPercent(c: { pages_done: number; pages_total: number | null }): number | null {
|
||||
return c.pages_total && c.pages_total > 0
|
||||
? Math.min(100, (c.pages_done / c.pages_total) * 100)
|
||||
: null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="titlebar">
|
||||
@@ -260,6 +352,12 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if status.current_cover}
|
||||
<p class="cover" data-testid="current-cover">
|
||||
🖼 Fetching cover: <strong>{status.current_cover.manga_title}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="lastpass">
|
||||
Last pass:
|
||||
{#if status.last_pass.at}
|
||||
@@ -288,7 +386,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Queue + workers -->
|
||||
<!-- Queue + covers stats -->
|
||||
<section class="grid2">
|
||||
<article>
|
||||
<h2>Queue</h2>
|
||||
@@ -299,27 +397,28 @@
|
||||
<dd>{status.queue.running}</dd>
|
||||
<dt>Dead</dt>
|
||||
<dd>{status.queue.dead}</dd>
|
||||
<dt>Covers queued</dt>
|
||||
<dd>{status.covers_queued}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Workers</h2>
|
||||
{#if status.workers.length === 0}
|
||||
<p class="muted">none</p>
|
||||
<h2>Active chapters ({status.active_chapters.length}/{status.worker_count})</h2>
|
||||
{#if status.active_chapters.length === 0}
|
||||
<p class="muted">idle — no chapters downloading</p>
|
||||
{:else}
|
||||
<table class="workers">
|
||||
<table class="active">
|
||||
<tbody>
|
||||
{#each status.workers as w, i (i)}
|
||||
{#each status.active_chapters as c (c.chapter_id)}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>
|
||||
<span
|
||||
class={`badge ${w.state === 'working' ? 'badge-downloading' : 'badge-not_downloaded'}`}
|
||||
>{w.state}</span
|
||||
>
|
||||
<td>{c.manga_title} · ch.{c.chapter_number}</td>
|
||||
<td class="pagecount" data-testid="active-pages">
|
||||
{c.pages_done}/{c.pages_total ?? '?'}
|
||||
</td>
|
||||
<td class="pagebar">
|
||||
{#if chapterPercent(c) !== null}
|
||||
{@render Bar({ percent: chapterPercent(c) ?? 0 })}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="mono"
|
||||
>{w.state === 'working' ? w.chapter_id : '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -331,6 +430,82 @@
|
||||
<p>Loading…</p>
|
||||
{/if}
|
||||
|
||||
<!-- Queued chapters (pending/running backlog) -->
|
||||
<section class="backlog">
|
||||
<div class="deadhead">
|
||||
<h2>Queued chapters ({activeTotal})</h2>
|
||||
<div class="deadtools">
|
||||
<input
|
||||
placeholder="Search manga…"
|
||||
bind:value={activeSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSearchActive()}
|
||||
/>
|
||||
<button onclick={onSearchActive}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if activeJobs.length === 0}
|
||||
<p class="muted">No chapters queued.</p>
|
||||
{:else}
|
||||
<table class="dead">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Manga / Chapter</th>
|
||||
<th>State</th>
|
||||
<th>Att.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each activeJobs as j (j.id)}
|
||||
<tr>
|
||||
<td>
|
||||
{j.manga_title ?? '(unknown)'}
|
||||
{#if j.chapter_number != null}· ch.{j.chapter_number}{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class={`badge ${j.state === 'running' ? 'badge-downloading' : 'badge-not_downloaded'}`}
|
||||
>{j.state}</span
|
||||
>
|
||||
</td>
|
||||
<td>{j.attempts}/{j.max_attempts}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={activePage} totalPages={activeTotalPages} onChange={onActivePageChange} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Queued covers (mangas missing a cover) -->
|
||||
<section class="backlog">
|
||||
<div class="deadhead">
|
||||
<h2>Queued covers ({coversTotal})</h2>
|
||||
<div class="deadtools">
|
||||
<input
|
||||
placeholder="Search manga…"
|
||||
bind:value={coversSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSearchCovers()}
|
||||
/>
|
||||
<button onclick={onSearchCovers}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if covers.length === 0}
|
||||
<p class="muted">No covers queued 🎉</p>
|
||||
{:else}
|
||||
<table class="dead">
|
||||
<thead>
|
||||
<tr><th>Manga</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each covers as c (c.manga_id)}
|
||||
<tr><td>{c.manga_title}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={coversPage} totalPages={coversTotalPages} onChange={onCoversPageChange} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Dead jobs -->
|
||||
<section class="deadjobs">
|
||||
<div class="deadhead">
|
||||
@@ -643,4 +818,21 @@
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.cover {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.backlog {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.pagecount {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagebar {
|
||||
width: 8rem;
|
||||
}
|
||||
table.active td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user