feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -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);
|
||||
|
||||
220
frontend/e2e/admin-crawler.spec.ts
Normal file
220
frontend/e2e/admin-crawler.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// E2E for the admin Crawler "History" tab: the Live/History toggle, the
|
||||
// searchable/filterable job log, and inline requeue of a dead job. The
|
||||
// live status + SSE stream are left pending so the test focuses on history
|
||||
// (the live dashboard is exercised by its own status mocks elsewhere).
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const deadJobId = 'd1111111-1111-1111-1111-111111111111';
|
||||
const doneJobId = 'd2222222-2222-2222-2222-222222222222';
|
||||
|
||||
const adminUser = {
|
||||
id: 'u11111111-1111-1111-1111-111111111111',
|
||||
username: 'admin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true
|
||||
};
|
||||
|
||||
type Captured = { requeue: Record<string, unknown> | null };
|
||||
|
||||
async function mockAdmin(page: Page, cap: Captured) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: adminUser })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/system', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
disk: null,
|
||||
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
|
||||
cpu: { percent_used: 0 },
|
||||
alerts: []
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Leave the live status fetch + SSE stream pending so the Live view
|
||||
// stays in its "Loading…" state and doesn't churn; History is what we test.
|
||||
await page.route('**/api/v1/admin/crawler', () => new Promise(() => {}));
|
||||
await page.route('**/api/v1/admin/crawler/stream', () => new Promise(() => {}));
|
||||
await page.route('**/api/v1/admin/crawler/dead-jobs**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 20, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/admin/crawler/history**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: deadJobId,
|
||||
state: 'dead',
|
||||
kind: 'sync_chapter_content',
|
||||
manga_id: 'm-1',
|
||||
manga_title: 'Naruto',
|
||||
chapter_id: 'c-1',
|
||||
chapter_number: 700,
|
||||
page_number: null,
|
||||
source_key: null,
|
||||
attempts: 5,
|
||||
max_attempts: 5,
|
||||
last_error: 'boom: upstream 500',
|
||||
updated_at: '2026-06-15T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: doneJobId,
|
||||
state: 'done',
|
||||
kind: 'analyze_page',
|
||||
manga_id: 'm-2',
|
||||
manga_title: 'Bleach',
|
||||
chapter_id: 'c-2',
|
||||
chapter_number: 3,
|
||||
page_number: 7,
|
||||
source_key: null,
|
||||
attempts: 1,
|
||||
max_attempts: 5,
|
||||
last_error: null,
|
||||
updated_at: '2026-06-15T00:00:00Z'
|
||||
}
|
||||
],
|
||||
page: { limit: 25, offset: 0, total: 2 }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Summary registered first (broad glob); the more-specific /ops route is
|
||||
// registered after so it wins for the ops URL (Playwright: last match wins).
|
||||
await page.route('**/api/v1/admin/crawler/metrics**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
summary: [
|
||||
{ op: 'manga_list', avg_ms: 42000, n: 8, ok: 8, failed: 0, avg_items: 50 },
|
||||
{ op: 'manga_detail', avg_ms: 1300, n: 210, ok: 205, failed: 5, avg_items: null },
|
||||
{ op: 'manga_cover', avg_ms: 480, n: 180, ok: 176, failed: 4, avg_items: null },
|
||||
{ op: 'chapter', avg_ms: 6800, n: 430, ok: 421, failed: 9, avg_items: 32 }
|
||||
]
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/crawler/metrics/ops**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 'op-1',
|
||||
op: 'chapter',
|
||||
manga_id: 'm-1',
|
||||
manga_title: 'Berserk',
|
||||
chapter_id: 'c-1',
|
||||
chapter_number: 12,
|
||||
outcome: 'ok',
|
||||
duration_ms: 6100,
|
||||
items: 20,
|
||||
error: null,
|
||||
finished_at: '2026-06-15T00:00:00Z'
|
||||
}
|
||||
],
|
||||
page: { limit: 25, offset: 0, total: 1 }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/admin/crawler/dead-jobs/requeue', (r) => {
|
||||
cap.requeue = JSON.parse(r.request().postData() ?? '{}');
|
||||
return r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ requeued: 1 })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('/admin/crawler history', () => {
|
||||
test('toggle to History lists jobs and requeues a dead job inline', async ({
|
||||
page
|
||||
}) => {
|
||||
const cap: Captured = { requeue: null };
|
||||
await mockAdmin(page, cap);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/crawler');
|
||||
|
||||
await page.getByTestId('crawler-tab-history').click();
|
||||
|
||||
const deadRow = page.getByTestId(`crawler-history-row-${deadJobId}`);
|
||||
await expect(deadRow).toContainText('Naruto · Ch 700');
|
||||
await expect(deadRow).toContainText('dead');
|
||||
|
||||
const doneRow = page.getByTestId(`crawler-history-row-${doneJobId}`);
|
||||
await expect(doneRow).toContainText('Bleach · Ch 3 · p7');
|
||||
|
||||
// Click the dead row to expand its error detail.
|
||||
await deadRow.click();
|
||||
await expect(
|
||||
page.getByTestId('crawler-history').locator('.errrow')
|
||||
).toContainText('boom: upstream 500');
|
||||
|
||||
// Inline requeue posts scope=job.
|
||||
await page.getByTestId(`crawler-history-requeue-${deadJobId}`).click();
|
||||
await expect.poll(() => cap.requeue).toEqual({ scope: 'job', job_id: deadJobId });
|
||||
});
|
||||
|
||||
test('Metrics tab shows averages by type, derived per-page, and the ops log', async ({
|
||||
page
|
||||
}) => {
|
||||
const cap: Captured = { requeue: null };
|
||||
await mockAdmin(page, cap);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/crawler');
|
||||
|
||||
await page.getByTestId('crawler-tab-metrics').click();
|
||||
|
||||
// Per-type averages.
|
||||
await expect(page.getByTestId('crawler-metrics-row-chapter')).toContainText('6.8s');
|
||||
await expect(page.getByTestId('crawler-metrics-row-manga_cover')).toContainText(
|
||||
'480ms'
|
||||
);
|
||||
// Derived per-page row: 6800ms ÷ 32 ≈ 213ms.
|
||||
await expect(page.getByTestId('crawler-metrics-perpage')).toContainText('213ms');
|
||||
|
||||
// Recent-ops log shows individual durations.
|
||||
await expect(page.getByTestId('crawler-op-op-1')).toContainText('6.1s');
|
||||
await expect(page.getByTestId('crawler-op-op-1')).toContainText('Berserk · Ch 12');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user