feat(admin): observability — job history, live "now analyzing", durations & metrics
Some checks failed
deploy / test-frontend (pull_request) Waiting to run
deploy / build-and-push (pull_request) Blocked by required conditions
deploy / deploy (pull_request) Blocked by required conditions
deploy / test-backend (pull_request) Has been cancelled

Crawler + analysis admin dashboards gain a Live / History / Metrics
segmented view.

History: searchable, filterable, paginated job log per subsystem
(crawler_jobs across all states/kinds; page_analysis terminal outcomes),
with inline dead-job requeue and a Duration column.

Live: enrich analysis SSE events with manga title + chapter number and add
a sticky "Now analyzing" banner that jumps to and highlights the page.

Metrics: new durable crawl_metrics table (migration 0028) timing every
crawl op (manga list walk, manga detail, cover, whole chapter; per-page
derived from chapter) plus page_analysis.duration_ms for analysis. New
endpoints serve per-type average durations + success rates and a recent-ops
log; a cron reaper (CRAWL_METRICS_RETENTION_DAYS) bounds growth.

Tested: repo + API integration tests, vitest for the API client and
fmtDuration, and Playwright for the History/Metrics tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-16 14:07:10 +02:00
parent 790549636f
commit 5fa4442904
41 changed files with 3655 additions and 21 deletions

View File

@@ -155,6 +155,48 @@ async function mockAdmin(page: Page, cap: Captured) {
})
);
// Analysis history (terminal-outcome log). Registered before the
// page-detail routes are matched by their more-specific globs.
await page.route('**/api/v1/admin/analysis/history**', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
page_id: pageDone,
page_number: 1,
chapter_id: chapterId,
chapter_number: 1,
manga_id: mangaId,
manga_title: 'Berserk',
status: 'done',
is_nsfw: true,
model: 'test-model',
error: null,
analyzed_at: '2026-06-13T12:00:00Z',
duration_ms: 2400
}
],
page: { limit: 25, offset: 0, total: 1 }
})
})
);
await page.route('**/api/v1/admin/analysis/metrics**', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
n: 3204,
ok: 3159,
failed: 45,
avg_ms: 2400,
by_model: [{ model: 'qwen2-vl-7b', avg_ms: 2400, n: 3100 }]
})
})
);
// Default: keep the SSE connection pending (no events) so tests that
// don't care about live updates don't trigger reconnect churn. The
// live-updates test overrides this with a fulfilling stream.
@@ -240,14 +282,18 @@ test.describe('/admin/analysis', () => {
kind: 'started',
page_id: pageNone,
manga_id: mangaId,
manga_title: 'Berserk',
chapter_id: chapterId,
chapter_number: 1,
page_number: 2
})}\n\n` +
`event: analysis\ndata: ${JSON.stringify({
kind: 'completed',
page_id: pageNone,
manga_id: mangaId,
manga_title: 'Berserk',
chapter_id: chapterId,
chapter_number: 1,
page_number: 2
})}\n\n`;
let served = false;
@@ -268,10 +314,81 @@ test.describe('/admin/analysis', () => {
// (The mocked stream closes after its body, so the live pill flips
// back to "Reconnecting…" — the ticker is the durable signal.)
const tick = page.getByTestId('admin-analysis-ticker');
await expect(tick).toContainText('Analyzing page 2');
await expect(tick).toContainText('Analyzing Berserk');
await expect(tick).toContainText('Analyzed page 2');
});
test('now-analyzing banner appears from SSE and Jump expands the chapter', async ({
page
}) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);
// A single `started` frame, then hang so the banner stays put.
const frame = `event: analysis\ndata: ${JSON.stringify({
kind: 'started',
page_id: pageDone,
manga_id: mangaId,
manga_title: 'Berserk',
chapter_id: chapterId,
chapter_number: 1,
page_number: 1
})}\n\n`;
let served = false;
await page.route('**/api/v1/admin/analysis/status/stream', (r) => {
if (served) return new Promise(() => {});
served = true;
return r.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: frame
});
});
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
const banner = page.getByTestId('admin-analysis-now');
await expect(banner).toContainText('Now analyzing');
await expect(banner).toContainText('Berserk · Ch 1 · Page 1');
// Jump expands the manga + chapter and the analyzing chip appears.
await page.getByTestId('admin-analysis-now-jump').click();
await expect(page.getByTestId(`admin-analysis-page-${pageDone}`)).toBeVisible();
});
test('history tab lists terminal analyses and opens the detail modal', async ({
page
}) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await page.getByTestId('admin-analysis-tab-history').click();
const row = page.getByTestId(`analysis-history-row-${pageDone}`);
await expect(row).toContainText('Berserk · Ch 1 · p1');
await expect(row).toContainText('NSFW');
await expect(row).toContainText('2.4s'); // duration column
await row.click();
await expect(page.getByTestId('admin-analysis-detail')).toBeVisible();
await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
'Analyzed'
);
});
test('metrics tab shows aggregate timing + by-model', async ({ page }) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await page.getByTestId('admin-analysis-tab-metrics').click();
await expect(page.getByTestId('analysis-metrics-n')).toContainText('3204');
await expect(page.getByTestId('analysis-metrics-avg')).toContainText('2.4s');
await expect(page.getByTestId('analysis-metrics')).toContainText('qwen2-vl-7b');
});
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);