feat(admin): Crawler dashboard — live status, controls, dead-job requeue
New /admin/crawler tab (5s-polled): status hero (daemon/session/browser pills, phase line + progress bar, session-expired banner, last-pass), controls (run pass, restart browser w/ confirm, manage session modal, clear expired), queue gauges + worker table, and a dead-jobs table with search, Pager, and per-job / per-manga / all requeue. Adds inline "requeue" on failed chapters in the admin manga page, the typed api-client functions in lib/api/admin.ts (+ tests), and the Crawler nav tab. Version 0.52.0 -> 0.53.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,14 @@ import {
|
|||||||
listAdminChapters,
|
listAdminChapters,
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
resyncManga,
|
resyncManga,
|
||||||
resyncChapter
|
resyncChapter,
|
||||||
|
getCrawlerStatus,
|
||||||
|
runCrawlerPass,
|
||||||
|
restartCrawlerBrowser,
|
||||||
|
updateCrawlerSession,
|
||||||
|
clearCrawlerSessionExpired,
|
||||||
|
listDeadJobs,
|
||||||
|
requeueDeadJobs
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -329,3 +336,87 @@ describe('admin api client', () => {
|
|||||||
expect(got.pages).toBeNull();
|
expect(got.pages).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admin crawler api client', () => {
|
||||||
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusFixture = {
|
||||||
|
daemon: 'running',
|
||||||
|
phase: { state: 'fetching_metadata', index: 3, total: 10, title: 'One Piece' },
|
||||||
|
workers: [{ state: 'idle' }],
|
||||||
|
last_pass: { at: null, discovered: 0, upserted: 0, covers_fetched: 0, mangas_failed: 0 },
|
||||||
|
session: { expired: false, configured: true },
|
||||||
|
browser: 'healthy',
|
||||||
|
queue: { pending: 2, running: 1, dead: 4 }
|
||||||
|
};
|
||||||
|
|
||||||
|
it('getCrawlerStatus GETs /v1/admin/crawler', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(statusFixture));
|
||||||
|
const s = await getCrawlerStatus();
|
||||||
|
expect(s.queue.dead).toBe(4);
|
||||||
|
expect(s.phase?.state).toBe('fetching_metadata');
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/crawler$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
|
||||||
|
const r = await runCrawlerPass();
|
||||||
|
expect(r.started).toBe(true);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/run$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restartCrawlerBrowser POSTs the restart endpoint', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ ok: true, error: null }));
|
||||||
|
const r = await restartCrawlerBrowser();
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/browser\/restart$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateCrawlerSession POSTs the phpsessid body', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ valid: true, error: null }));
|
||||||
|
const r = await updateCrawlerSession('abc123');
|
||||||
|
expect(r.valid).toBe(true);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ phpsessid: 'abc123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCrawlerSessionExpired POSTs clear-expired', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ cleared: true }));
|
||||||
|
const r = await clearCrawlerSessionExpired();
|
||||||
|
expect(r.cleared).toBe(true);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/session\/clear-expired$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listDeadJobs forwards search + pagination', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [], page: { limit: 20, offset: 20, total: 0 } })
|
||||||
|
);
|
||||||
|
await listDeadJobs({ search: 'naruto', limit: 20, offset: 20 });
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('search=naruto');
|
||||||
|
expect(url).toContain('offset=20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requeueDeadJobs POSTs the scope body', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok({ requeued: 3 }));
|
||||||
|
const r = await requeueDeadJobs({ scope: 'manga', manga_id: 'm-9' });
|
||||||
|
expect(r.requeued).toBe(3);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ scope: 'manga', manga_id: 'm-9' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a 503 as ApiError', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
|
||||||
|
await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -214,3 +214,105 @@ export async function resyncChapter(id: string): Promise<ChapterResyncResponse>
|
|||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- crawler observability + control ---------------------------------------
|
||||||
|
|
||||||
|
/** Current daemon activity. Discriminated on `state`. */
|
||||||
|
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' };
|
||||||
|
|
||||||
|
export type CrawlerWorker = { state: 'idle' } | { state: 'working'; chapter_id: string };
|
||||||
|
|
||||||
|
export type CrawlerLastPass = {
|
||||||
|
at: string | null;
|
||||||
|
discovered: number;
|
||||||
|
upserted: number;
|
||||||
|
covers_fetched: number;
|
||||||
|
mangas_failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrawlerStatus = {
|
||||||
|
daemon: 'running' | 'disabled';
|
||||||
|
phase: CrawlerPhase | null;
|
||||||
|
workers: CrawlerWorker[];
|
||||||
|
last_pass: CrawlerLastPass;
|
||||||
|
session: { expired: boolean; configured: boolean };
|
||||||
|
browser: 'healthy' | 'draining' | 'restarting' | 'down';
|
||||||
|
queue: { pending: number; running: number; dead: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCrawlerStatus(): Promise<CrawlerStatus> {
|
||||||
|
return request<CrawlerStatus>('/v1/admin/crawler');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/admin/crawler/run — trigger an out-of-cycle metadata pass. */
|
||||||
|
export async function runCrawlerPass(): Promise<{ started: boolean }> {
|
||||||
|
return request('/v1/admin/crawler/run', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/admin/crawler/browser/restart — coordinated Chromium restart. */
|
||||||
|
export async function restartCrawlerBrowser(): Promise<{ ok: boolean; error: string | null }> {
|
||||||
|
return request('/v1/admin/crawler/browser/restart', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/admin/crawler/session — refresh PHPSESSID and re-probe. */
|
||||||
|
export async function updateCrawlerSession(
|
||||||
|
phpsessid: string
|
||||||
|
): Promise<{ valid: boolean; error: string | null }> {
|
||||||
|
return request('/v1/admin/crawler/session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ phpsessid })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/admin/crawler/session/clear-expired — resume idled workers. */
|
||||||
|
export async function clearCrawlerSessionExpired(): Promise<{ cleared: boolean }> {
|
||||||
|
return request('/v1/admin/crawler/session/clear-expired', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeadJob = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
chapter_id: string | null;
|
||||||
|
manga_id: string | null;
|
||||||
|
manga_title: string | null;
|
||||||
|
chapter_number: number | null;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
last_error: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeadJobsPage = { items: DeadJob[]; page: Page };
|
||||||
|
|
||||||
|
export async function listDeadJobs(opts?: {
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<DeadJobsPage> {
|
||||||
|
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<DeadJobsPage>(`/v1/admin/crawler/dead-jobs${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requeue scope: all dead jobs, one manga's, one chapter's, or a single job. */
|
||||||
|
export type RequeueScope =
|
||||||
|
| { scope: 'all' }
|
||||||
|
| { scope: 'manga'; manga_id: string }
|
||||||
|
| { scope: 'chapter'; chapter_id: string }
|
||||||
|
| { scope: 'job'; job_id: string };
|
||||||
|
|
||||||
|
export async function requeueDeadJobs(scope: RequeueScope): Promise<{ requeued: number }> {
|
||||||
|
return request('/v1/admin/crawler/dead-jobs/requeue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(scope)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{ href: '/admin', label: 'Overview' },
|
{ href: '/admin', label: 'Overview' },
|
||||||
{ href: '/admin/users', label: 'Users' },
|
{ href: '/admin/users', label: 'Users' },
|
||||||
{ href: '/admin/mangas', label: 'Mangas' },
|
{ href: '/admin/mangas', label: 'Mangas' },
|
||||||
|
{ href: '/admin/crawler', label: 'Crawler' },
|
||||||
{ href: '/admin/system', label: 'System' }
|
{ href: '/admin/system', label: 'System' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
599
frontend/src/routes/admin/crawler/+page.svelte
Normal file
599
frontend/src/routes/admin/crawler/+page.svelte
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
|
import {
|
||||||
|
getCrawlerStatus,
|
||||||
|
runCrawlerPass,
|
||||||
|
restartCrawlerBrowser,
|
||||||
|
updateCrawlerSession,
|
||||||
|
clearCrawlerSessionExpired,
|
||||||
|
listDeadJobs,
|
||||||
|
requeueDeadJobs,
|
||||||
|
type CrawlerStatus,
|
||||||
|
type CrawlerPhase,
|
||||||
|
type DeadJob,
|
||||||
|
type RequeueScope
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
|
||||||
|
let status: CrawlerStatus | null = $state(null);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let notice: string | null = $state(null);
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// Dead jobs
|
||||||
|
let deadJobs: DeadJob[] = $state([]);
|
||||||
|
let deadTotal = $state(0);
|
||||||
|
let deadSearch = $state('');
|
||||||
|
let deadPage = $state(1);
|
||||||
|
const DEAD_LIMIT = 20;
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
let sessionModalOpen = $state(false);
|
||||||
|
let restartModalOpen = $state(false);
|
||||||
|
let phpsessid = $state('');
|
||||||
|
let sessionResult: string | null = $state(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
status = await getCrawlerStatus();
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'refresh failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeadJobs() {
|
||||||
|
try {
|
||||||
|
const resp = await listDeadJobs({
|
||||||
|
search: deadSearch.trim() || undefined,
|
||||||
|
limit: DEAD_LIMIT,
|
||||||
|
offset: (deadPage - 1) * DEAD_LIMIT
|
||||||
|
});
|
||||||
|
deadJobs = resp.items;
|
||||||
|
deadTotal = resp.page.total ?? resp.items.length;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'failed to load dead jobs';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refresh();
|
||||||
|
loadDeadJobs();
|
||||||
|
timer = setInterval(refresh, 5000);
|
||||||
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function withBusy(label: string, fn: () => Promise<void>) {
|
||||||
|
busy = true;
|
||||||
|
notice = null;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : `${label} failed`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunPass() {
|
||||||
|
await withBusy('run pass', async () => {
|
||||||
|
await runCrawlerPass();
|
||||||
|
notice = 'Metadata pass started.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmRestart() {
|
||||||
|
restartModalOpen = false;
|
||||||
|
await withBusy('restart browser', async () => {
|
||||||
|
const r = await restartCrawlerBrowser();
|
||||||
|
notice = r.ok ? 'Browser restarted.' : `Restart failed: ${r.error ?? 'unknown'}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveSession() {
|
||||||
|
sessionResult = null;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const r = await updateCrawlerSession(phpsessid);
|
||||||
|
sessionResult = r.valid
|
||||||
|
? '✓ Session valid — workers resumed.'
|
||||||
|
: `✕ Probe failed: ${r.error ?? 'unauthenticated'}`;
|
||||||
|
if (r.valid) {
|
||||||
|
sessionModalOpen = false;
|
||||||
|
phpsessid = '';
|
||||||
|
notice = 'Session updated.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sessionResult = e instanceof Error ? e.message : 'update failed';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearExpired() {
|
||||||
|
await withBusy('clear expired', async () => {
|
||||||
|
await clearCrawlerSessionExpired();
|
||||||
|
notice = 'Session-expired flag cleared.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requeue(scope: RequeueScope) {
|
||||||
|
await withBusy('requeue', async () => {
|
||||||
|
const r = await requeueDeadJobs(scope);
|
||||||
|
notice = `Requeued ${r.requeued} job(s).`;
|
||||||
|
await loadDeadJobs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchDead() {
|
||||||
|
deadPage = 1;
|
||||||
|
loadDeadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeadPageChange(p: number) {
|
||||||
|
deadPage = p;
|
||||||
|
loadDeadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- display helpers ----
|
||||||
|
function phaseLabel(p: CrawlerPhase | null): string {
|
||||||
|
if (!p) return 'Daemon disabled';
|
||||||
|
switch (p.state) {
|
||||||
|
case 'idle':
|
||||||
|
return p.next_fire
|
||||||
|
? `Idle — next pass ${new Date(p.next_fire).toLocaleString()}`
|
||||||
|
: 'Idle';
|
||||||
|
case 'walking_list':
|
||||||
|
return 'Walking source list';
|
||||||
|
case 'fetching_metadata':
|
||||||
|
return `Fetching metadata · ${p.index}/${p.total ?? '?'} · ${p.title}`;
|
||||||
|
case 'cover_backfill':
|
||||||
|
return 'Backfilling covers';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function phasePercent(p: CrawlerPhase | null): number | null {
|
||||||
|
if (p && p.state === 'fetching_metadata' && p.total && p.total > 0) {
|
||||||
|
return Math.min(100, (p.index / p.total) * 100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionPill(s: CrawlerStatus): { cls: string; text: string } {
|
||||||
|
if (s.daemon === 'disabled') return { cls: 'badge-not_downloaded', text: 'n/a' };
|
||||||
|
if (s.session.expired) return { cls: 'badge-in_progress', text: 'Expired' };
|
||||||
|
if (!s.session.configured) return { cls: 'badge-not_downloaded', text: 'Not set' };
|
||||||
|
return { cls: 'badge-synced', text: 'OK' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserPill(s: CrawlerStatus): { cls: string; text: string } {
|
||||||
|
switch (s.browser) {
|
||||||
|
case 'healthy':
|
||||||
|
return { cls: 'badge-synced', text: 'Up' };
|
||||||
|
case 'draining':
|
||||||
|
case 'restarting':
|
||||||
|
return { cls: 'badge-in_progress', text: s.browser };
|
||||||
|
default:
|
||||||
|
return { cls: 'badge-not_downloaded', text: 'Down' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadTotalPages = $derived(Math.max(1, Math.ceil(deadTotal / DEAD_LIMIT)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Crawler</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if notice}
|
||||||
|
<p class="notice" role="status">{notice}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status}
|
||||||
|
<!-- Status hero -->
|
||||||
|
<section class="hero" data-testid="crawler-hero">
|
||||||
|
<div class="pills">
|
||||||
|
<span class="pill"
|
||||||
|
>Daemon
|
||||||
|
<span class={`badge ${status.daemon === 'running' ? 'badge-synced' : 'badge-not_downloaded'}`}
|
||||||
|
>{status.daemon}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<span class="pill"
|
||||||
|
>Session <span class={`badge ${sessionPill(status).cls}`}>{sessionPill(status).text}</span></span
|
||||||
|
>
|
||||||
|
<span class="pill"
|
||||||
|
>Browser <span class={`badge ${browserPill(status).cls}`}>{browserPill(status).text}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="phase" data-testid="crawler-phase">{phaseLabel(status.phase)}</p>
|
||||||
|
{#if phasePercent(status.phase) !== null}
|
||||||
|
{@render Bar({ percent: phasePercent(status.phase) ?? 0 })}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status.session.expired}
|
||||||
|
<p class="warn">
|
||||||
|
⚠ Chapter downloads paused — session expired. Metadata + list crawl continue.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="lastpass">
|
||||||
|
Last pass:
|
||||||
|
{#if status.last_pass.at}
|
||||||
|
{new Date(status.last_pass.at).toLocaleString()} ·
|
||||||
|
{status.last_pass.discovered} seen · {status.last_pass.upserted} upserted ·
|
||||||
|
{status.last_pass.mangas_failed} failed
|
||||||
|
{:else}
|
||||||
|
— none yet this session
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<section class="controls">
|
||||||
|
<button onclick={onRunPass} disabled={busy || status.daemon !== 'running'}
|
||||||
|
>Run metadata pass now</button
|
||||||
|
>
|
||||||
|
<button onclick={() => (restartModalOpen = true)} disabled={busy || status.daemon !== 'running'}
|
||||||
|
>Restart browser</button
|
||||||
|
>
|
||||||
|
<button onclick={() => { sessionModalOpen = true; sessionResult = null; }} disabled={busy || status.daemon !== 'running'}
|
||||||
|
>Manage session…</button
|
||||||
|
>
|
||||||
|
{#if status.session.expired}
|
||||||
|
<button onclick={onClearExpired} disabled={busy}>Clear expired flag</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Queue + workers -->
|
||||||
|
<section class="grid2">
|
||||||
|
<article>
|
||||||
|
<h2>Queue</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Pending</dt>
|
||||||
|
<dd>{status.queue.pending}</dd>
|
||||||
|
<dt>Running</dt>
|
||||||
|
<dd>{status.queue.running}</dd>
|
||||||
|
<dt>Dead</dt>
|
||||||
|
<dd>{status.queue.dead}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h2>Workers</h2>
|
||||||
|
{#if status.workers.length === 0}
|
||||||
|
<p class="muted">none</p>
|
||||||
|
{:else}
|
||||||
|
<table class="workers">
|
||||||
|
<tbody>
|
||||||
|
{#each status.workers as w, i (i)}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class={`badge ${w.state === 'working' ? 'badge-downloading' : 'badge-not_downloaded'}`}
|
||||||
|
>{w.state}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="mono"
|
||||||
|
>{w.state === 'working' ? w.chapter_id : '—'}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dead jobs -->
|
||||||
|
<section class="deadjobs">
|
||||||
|
<div class="deadhead">
|
||||||
|
<h2>Dead jobs ({deadTotal})</h2>
|
||||||
|
<div class="deadtools">
|
||||||
|
<input
|
||||||
|
placeholder="Search manga…"
|
||||||
|
bind:value={deadSearch}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onSearchDead()}
|
||||||
|
/>
|
||||||
|
<button onclick={onSearchDead}>Search</button>
|
||||||
|
<button
|
||||||
|
onclick={() => requeue({ scope: 'all' })}
|
||||||
|
disabled={busy || deadTotal === 0}>Requeue all ({deadTotal})</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if deadJobs.length === 0}
|
||||||
|
<p class="muted">No dead jobs 🎉</p>
|
||||||
|
{:else}
|
||||||
|
<table class="dead">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Manga / Chapter</th>
|
||||||
|
<th>Att.</th>
|
||||||
|
<th>Failed</th>
|
||||||
|
<th>Last error</th>
|
||||||
|
<th class="actions">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each deadJobs as j (j.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{j.manga_title ?? '(unknown)'}
|
||||||
|
{#if j.chapter_number != null}· ch.{j.chapter_number}{/if}
|
||||||
|
</td>
|
||||||
|
<td>{j.attempts}/{j.max_attempts}</td>
|
||||||
|
<td>{new Date(j.updated_at).toLocaleDateString()}</td>
|
||||||
|
<td class="err" title={j.last_error ?? ''}>{j.last_error ?? '—'}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button onclick={() => requeue({ scope: 'job', job_id: j.id })} disabled={busy}
|
||||||
|
>Requeue</button
|
||||||
|
>
|
||||||
|
{#if j.manga_id}
|
||||||
|
<button
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => requeue({ scope: 'manga', manga_id: j.manga_id! })}
|
||||||
|
disabled={busy}>Manga</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pager page={deadPage} totalPages={deadTotalPages} onChange={onDeadPageChange} />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Restart confirm modal -->
|
||||||
|
<Modal open={restartModalOpen} title="Restart browser" onClose={() => (restartModalOpen = false)} size="sm">
|
||||||
|
{#snippet children()}
|
||||||
|
<p>This relaunches Chromium and re-injects the session cookie.</p>
|
||||||
|
<ul class="coord">
|
||||||
|
<li>In-flight jobs are allowed to finish (bounded), then forced.</li>
|
||||||
|
<li>New jobs pause until the relaunch completes.</li>
|
||||||
|
<li>The metadata pass yields at its next checkpoint.</li>
|
||||||
|
</ul>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button onclick={() => (restartModalOpen = false)}>Cancel</button>
|
||||||
|
<button class="primary" onclick={onConfirmRestart} disabled={busy}>Restart</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Session modal -->
|
||||||
|
<Modal open={sessionModalOpen} title="Manage crawler session" onClose={() => (sessionModalOpen = false)} size="md">
|
||||||
|
{#snippet children()}
|
||||||
|
<label for="phpsessid">PHPSESSID</label>
|
||||||
|
<input id="phpsessid" type="password" bind:value={phpsessid} autocomplete="off" />
|
||||||
|
<p class="hint">
|
||||||
|
Saving rewrites the cookie everywhere, persists it, restarts the browser, and re-probes.
|
||||||
|
</p>
|
||||||
|
{#if sessionResult}
|
||||||
|
<p class="sessionresult">{sessionResult}</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button onclick={() => (sessionModalOpen = false)}>Cancel</button>
|
||||||
|
<button class="primary" onclick={onSaveSession} disabled={busy || phpsessid.trim() === ''}
|
||||||
|
>Save & validate</button
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{#snippet Bar({ percent }: { percent: number })}
|
||||||
|
<div class="bar" role="progressbar" aria-valuenow={percent} aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="fill" style:width="{Math.min(100, Math.max(0, percent))}%"></div>
|
||||||
|
<span class="label">{percent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.pills {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.phase {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
.lastpass,
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
article {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--space-1) var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
max-width: 22rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.deadhead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.deadtools {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
color: var(--success, #0a7d2c);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--success, #0a7d2c);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.sessionresult {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.coord {
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
/* badges (shared convention with admin/mangas) */
|
||||||
|
:global(.badge) {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
:global(.badge-synced) {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
border-color: #86efac;
|
||||||
|
}
|
||||||
|
:global(.badge-in_progress),
|
||||||
|
:global(.badge-downloading) {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
:global(.badge-not_downloaded) {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
height: 1.5rem;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--danger, #dc2626);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
listAdminMangas,
|
listAdminMangas,
|
||||||
listAdminChapters,
|
listAdminChapters,
|
||||||
|
requeueDeadJobs,
|
||||||
type AdminMangasPage,
|
type AdminMangasPage,
|
||||||
type AdminChapterRow,
|
type AdminChapterRow,
|
||||||
type MangaSyncState
|
type MangaSyncState
|
||||||
@@ -59,6 +60,27 @@
|
|||||||
function badgeClass(state: string): string {
|
function badgeClass(state: string): string {
|
||||||
return `badge badge-${state}`;
|
return `badge badge-${state}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requeuingChapter: string | null = $state(null);
|
||||||
|
|
||||||
|
/** Requeue the dead job(s) for a single failed chapter, then refresh
|
||||||
|
* that manga's chapter list so the pill updates. */
|
||||||
|
async function requeueChapter(mangaId: string, chapterId: string) {
|
||||||
|
requeuingChapter = chapterId;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await requeueDeadJobs({ scope: 'chapter', chapter_id: chapterId });
|
||||||
|
const resp = await listAdminChapters(mangaId, { limit: 500 });
|
||||||
|
chaptersByManga[mangaId] = {
|
||||||
|
items: resp.items,
|
||||||
|
total: resp.page.total ?? resp.items.length
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'requeue failed';
|
||||||
|
} finally {
|
||||||
|
requeuingChapter = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Mangas</h1>
|
<h1>Mangas</h1>
|
||||||
@@ -153,6 +175,16 @@
|
|||||||
<span class={badgeClass(c.sync_state)}>
|
<span class={badgeClass(c.sync_state)}>
|
||||||
{c.sync_state}
|
{c.sync_state}
|
||||||
</span>
|
</span>
|
||||||
|
{#if c.sync_state === 'failed'}
|
||||||
|
<button
|
||||||
|
class="requeue"
|
||||||
|
onclick={() => requeueChapter(m.id, c.id)}
|
||||||
|
disabled={requeuingChapter === c.id}
|
||||||
|
title="Requeue this chapter"
|
||||||
|
>
|
||||||
|
↻ requeue
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -272,6 +304,11 @@
|
|||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
border-color: #fca5a5;
|
border-color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
.requeue {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
.badge-not_downloaded {
|
.badge-not_downloaded {
|
||||||
background: var(--surface-elevated);
|
background: var(--surface-elevated);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user