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,
|
||||
getSystemStats,
|
||||
resyncManga,
|
||||
resyncChapter
|
||||
resyncChapter,
|
||||
getCrawlerStatus,
|
||||
runCrawlerPass,
|
||||
restartCrawlerBrowser,
|
||||
updateCrawlerSession,
|
||||
clearCrawlerSessionExpired,
|
||||
listDeadJobs,
|
||||
requeueDeadJobs
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -329,3 +336,87 @@ describe('admin api client', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user