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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.82.0",
|
||||
"version": "0.84.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -35,7 +35,12 @@ import {
|
||||
getAnalysisChapterCoverage,
|
||||
getAnalysisChapterPages,
|
||||
getAnalysisPageDetail,
|
||||
analysisStatusStreamUrl
|
||||
analysisStatusStreamUrl,
|
||||
listCrawlerJobHistory,
|
||||
listAnalysisHistory,
|
||||
getCrawlerMetrics,
|
||||
listCrawlerOps,
|
||||
getAnalysisMetrics
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -651,4 +656,152 @@ describe('admin crawler api client', () => {
|
||||
expect(url).toMatch(/\/v1\/admin\/storage\/backfill$/);
|
||||
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
||||
});
|
||||
|
||||
// ---- job history ----
|
||||
|
||||
it('listCrawlerJobHistory forwards state/kind/search/limit/offset and parses items', async () => {
|
||||
const row = {
|
||||
id: 'j-1',
|
||||
state: 'done',
|
||||
kind: 'analyze_page',
|
||||
manga_id: 'm-1',
|
||||
manga_title: 'Berserk',
|
||||
chapter_id: 'c-1',
|
||||
chapter_number: 12,
|
||||
page_number: 7,
|
||||
source_key: null,
|
||||
attempts: 1,
|
||||
max_attempts: 5,
|
||||
last_error: null,
|
||||
updated_at: '2026-06-15T00:00:00Z'
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } })
|
||||
);
|
||||
const page = await listCrawlerJobHistory({
|
||||
state: 'done',
|
||||
kind: 'analyze_page',
|
||||
search: 'Ber',
|
||||
offset: 10
|
||||
});
|
||||
expect(page.items[0]).toEqual(row);
|
||||
expect(page.page.total).toBe(1);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/admin/crawler/history?');
|
||||
expect(url).toContain('state=done');
|
||||
expect(url).toContain('kind=analyze_page');
|
||||
expect(url).toContain('search=Ber');
|
||||
expect(url).toContain('offset=10');
|
||||
});
|
||||
|
||||
it('listCrawlerJobHistory omits unset filters', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listCrawlerJobHistory();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/crawler\/history$/);
|
||||
});
|
||||
|
||||
it('listAnalysisHistory forwards status/nsfw/search and parses items', async () => {
|
||||
const row = {
|
||||
page_id: 'p-1',
|
||||
page_number: 3,
|
||||
chapter_id: 'c-1',
|
||||
chapter_number: 700,
|
||||
manga_id: 'm-1',
|
||||
manga_title: 'Naruto',
|
||||
status: 'failed',
|
||||
is_nsfw: false,
|
||||
model: null,
|
||||
error: 'boom',
|
||||
analyzed_at: '2026-06-15T00:00:00Z'
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [row], page: { limit: 25, offset: 0, total: 1 } })
|
||||
);
|
||||
const page = await listAnalysisHistory({ status: 'failed', nsfw: true, search: 'Nar' });
|
||||
expect(page.items[0]).toEqual(row);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/admin/analysis/history?');
|
||||
expect(url).toContain('status=failed');
|
||||
expect(url).toContain('nsfw=true');
|
||||
expect(url).toContain('search=Nar');
|
||||
});
|
||||
|
||||
it('listAnalysisHistory omits nsfw when false', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
|
||||
);
|
||||
await listAnalysisHistory({ status: 'done', nsfw: false });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('status=done');
|
||||
expect(url).not.toContain('nsfw');
|
||||
});
|
||||
|
||||
// ---- operation metrics ----
|
||||
|
||||
it('getCrawlerMetrics omits days when 0 and parses the summary', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
summary: [
|
||||
{ op: 'chapter', avg_ms: 7000, n: 2, ok: 1, failed: 1, avg_items: 15 }
|
||||
]
|
||||
})
|
||||
);
|
||||
const r = await getCrawlerMetrics(0);
|
||||
expect(r.summary[0].avg_ms).toBe(7000);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/crawler\/metrics$/);
|
||||
});
|
||||
|
||||
it('getCrawlerMetrics forwards a positive days window', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ summary: [] }));
|
||||
await getCrawlerMetrics(7);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('days=7');
|
||||
});
|
||||
|
||||
it('listCrawlerOps forwards op/outcome/days and parses the page', async () => {
|
||||
const row = {
|
||||
id: 'o-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'
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } })
|
||||
);
|
||||
const page = await listCrawlerOps({ op: 'chapter', outcome: 'ok', days: 30 });
|
||||
expect(page.items[0]).toEqual(row);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/admin/crawler/metrics/ops?');
|
||||
expect(url).toContain('op=chapter');
|
||||
expect(url).toContain('outcome=ok');
|
||||
expect(url).toContain('days=30');
|
||||
});
|
||||
|
||||
it('getAnalysisMetrics parses aggregate + by-model', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
n: 4,
|
||||
ok: 3,
|
||||
failed: 1,
|
||||
avg_ms: 2500,
|
||||
by_model: [{ model: 'qwen', avg_ms: 3000, n: 2 }]
|
||||
})
|
||||
);
|
||||
const m = await getAnalysisMetrics(7);
|
||||
expect(m.avg_ms).toBe(2500);
|
||||
expect(m.by_model[0].model).toBe('qwen');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/admin/analysis/metrics?days=7');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,6 +436,128 @@ export async function listActiveJobs(
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue states a crawler job can be in. */
|
||||
export type CrawlerJobState = 'pending' | 'running' | 'done' | 'dead';
|
||||
|
||||
/** Job kinds the crawler/analysis queue carries. */
|
||||
export type CrawlerJobKind =
|
||||
| 'sync_manga'
|
||||
| 'sync_chapter_list'
|
||||
| 'sync_chapter_content'
|
||||
| 'analyze_page';
|
||||
|
||||
/** One row in the crawler job-history table — any state/kind, resolved to
|
||||
* its manga/chapter/page context (best-effort `null`s for kinds that don't
|
||||
* carry that reference, e.g. a bootstrap `sync_manga`). */
|
||||
export type CrawlerHistoryRow = {
|
||||
id: string;
|
||||
state: CrawlerJobState;
|
||||
kind: CrawlerJobKind | null;
|
||||
manga_id: string | null;
|
||||
manga_title: string | null;
|
||||
chapter_id: string | null;
|
||||
chapter_number: number | null;
|
||||
page_number: number | null;
|
||||
source_key: string | null;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
last_error: string | null;
|
||||
updated_at: string;
|
||||
/** Recorded duration for a chapter / analyze job; `null` otherwise. */
|
||||
duration_ms: number | null;
|
||||
};
|
||||
|
||||
export type CrawlerHistoryPage = { items: CrawlerHistoryRow[]; page: Page };
|
||||
|
||||
/** GET /v1/admin/crawler/history — unified, searchable/filterable job log.
|
||||
* History depth is bounded by the done-job reaper (recent window); `dead`
|
||||
* jobs persist until requeued. */
|
||||
export async function listCrawlerJobHistory(
|
||||
opts?: {
|
||||
state?: CrawlerJobState;
|
||||
kind?: CrawlerJobKind;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
init?: RequestInit
|
||||
): Promise<CrawlerHistoryPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.state) params.set('state', opts.state);
|
||||
if (opts?.kind) params.set('kind', opts.kind);
|
||||
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<CrawlerHistoryPage>(
|
||||
`/v1/admin/crawler/history${qs ? `?${qs}` : ''}`,
|
||||
init
|
||||
);
|
||||
}
|
||||
|
||||
// ---- operation metrics (durations / averages) ------------------------------
|
||||
|
||||
/** Crawl operation kinds timed in `crawl_metrics`. */
|
||||
export type CrawlOp = 'manga_list' | 'manga_detail' | 'manga_cover' | 'chapter';
|
||||
|
||||
/** Per-op average roll-up over the selected window. */
|
||||
export type OpSummary = {
|
||||
op: CrawlOp;
|
||||
avg_ms: number | null;
|
||||
n: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
avg_items: number | null;
|
||||
};
|
||||
|
||||
/** One timed operation in the recent-ops log. */
|
||||
export type OpRow = {
|
||||
id: string;
|
||||
op: CrawlOp;
|
||||
manga_id: string | null;
|
||||
manga_title: string | null;
|
||||
chapter_id: string | null;
|
||||
chapter_number: number | null;
|
||||
outcome: 'ok' | 'failed';
|
||||
duration_ms: number;
|
||||
items: number | null;
|
||||
error: string | null;
|
||||
finished_at: string;
|
||||
};
|
||||
|
||||
export type CrawlerOpsPage = { items: OpRow[]; page: Page };
|
||||
|
||||
/** GET /v1/admin/crawler/metrics — per-type average durations + success.
|
||||
* `days` windows the rows (0/omitted = all time). */
|
||||
export async function getCrawlerMetrics(days = 0): Promise<{ summary: OpSummary[] }> {
|
||||
const qs = days > 0 ? `?days=${days}` : '';
|
||||
return request<{ summary: OpSummary[] }>(`/v1/admin/crawler/metrics${qs}`);
|
||||
}
|
||||
|
||||
/** GET /v1/admin/crawler/metrics/ops — paginated recent timed-operations log. */
|
||||
export async function listCrawlerOps(
|
||||
opts?: {
|
||||
op?: CrawlOp;
|
||||
outcome?: 'ok' | 'failed';
|
||||
days?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
init?: RequestInit
|
||||
): Promise<CrawlerOpsPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.op) params.set('op', opts.op);
|
||||
if (opts?.outcome) params.set('outcome', opts.outcome);
|
||||
if (opts?.days != null && opts.days > 0) params.set('days', String(opts.days));
|
||||
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<CrawlerOpsPage>(
|
||||
`/v1/admin/crawler/metrics/ops${qs ? `?${qs}` : ''}`,
|
||||
init
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
@@ -588,6 +710,71 @@ export async function getAnalysisPageDetail(
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the analysis history table — a terminal (done/failed)
|
||||
* analysis pass resolved to its page/chapter/manga context. Persists
|
||||
* indefinitely (sourced from `page_analysis`, not the reaped job queue). */
|
||||
export type AnalysisHistoryRow = {
|
||||
page_id: string;
|
||||
page_number: number;
|
||||
chapter_id: string;
|
||||
chapter_number: number;
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
status: 'done' | 'failed';
|
||||
is_nsfw: boolean;
|
||||
model: string | null;
|
||||
error: string | null;
|
||||
analyzed_at: string | null;
|
||||
/** Wall-clock the worker spent; `null` for pre-tracking rows. */
|
||||
duration_ms: number | null;
|
||||
};
|
||||
|
||||
export type AnalysisHistoryPage = { items: AnalysisHistoryRow[]; page: Page };
|
||||
|
||||
/** GET /v1/admin/analysis/history — searchable/filterable log of completed
|
||||
* and failed page analyses, newest first. */
|
||||
export async function listAnalysisHistory(
|
||||
opts?: {
|
||||
status?: 'done' | 'failed';
|
||||
nsfw?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
init?: RequestInit
|
||||
): Promise<AnalysisHistoryPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.status) params.set('status', opts.status);
|
||||
if (opts?.nsfw) params.set('nsfw', 'true');
|
||||
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<AnalysisHistoryPage>(
|
||||
`/v1/admin/analysis/history${qs ? `?${qs}` : ''}`,
|
||||
init
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-model average analysis duration. */
|
||||
export type ModelDuration = { model: string | null; avg_ms: number | null; n: number };
|
||||
|
||||
/** Aggregate analysis timing/outcome roll-up over the selected window. */
|
||||
export type AnalysisMetrics = {
|
||||
n: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
avg_ms: number | null;
|
||||
by_model: ModelDuration[];
|
||||
};
|
||||
|
||||
/** GET /v1/admin/analysis/metrics — avg duration, success, by-model.
|
||||
* `days` windows the rows (0/omitted = all time). */
|
||||
export async function getAnalysisMetrics(days = 0): Promise<AnalysisMetrics> {
|
||||
const qs = days > 0 ? `?days=${days}` : '';
|
||||
return request<AnalysisMetrics>(`/v1/admin/analysis/metrics${qs}`);
|
||||
}
|
||||
|
||||
/** One live analysis event from the SSE stream. */
|
||||
export type AnalysisEvent =
|
||||
| {
|
||||
@@ -600,7 +787,9 @@ export type AnalysisEvent =
|
||||
kind: 'started' | 'completed' | 'failed';
|
||||
page_id: string;
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
chapter_id: string;
|
||||
chapter_number: number;
|
||||
page_number: number;
|
||||
};
|
||||
|
||||
|
||||
229
frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte
Normal file
229
frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import {
|
||||
listAnalysisHistory,
|
||||
type AnalysisHistoryRow
|
||||
} from '$lib/api/admin';
|
||||
|
||||
// Row click opens the parent's existing page-detail modal — history
|
||||
// reuses the same drill-down inspector as the coverage grid.
|
||||
let {
|
||||
onOpenDetail
|
||||
}: {
|
||||
onOpenDetail: (pageId: string) => void;
|
||||
} = $props();
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let rows = $state<AnalysisHistoryRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
||||
let nsfwOnly = $state(false);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const resp = await listAnalysisHistory({
|
||||
status: statusFilter || undefined,
|
||||
nsfw: nsfwOnly,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load history.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function applyFilters() {
|
||||
page = 1;
|
||||
load();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
load();
|
||||
}
|
||||
function onSearchKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="history" data-testid="analysis-history">
|
||||
<div class="toolbar">
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by status"
|
||||
data-testid="analysis-history-status"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="done">done</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<label class="nsfw">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={nsfwOnly}
|
||||
onchange={applyFilters}
|
||||
data-testid="analysis-history-nsfw"
|
||||
/>
|
||||
NSFW only
|
||||
</label>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search manga title…"
|
||||
onkeydown={onSearchKey}
|
||||
data-testid="analysis-history-search"
|
||||
/>
|
||||
<button type="button" onclick={applyFilters}>Search</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted" data-testid="analysis-history-loading">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="analysis-history-empty">No analyses match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Page</th>
|
||||
<th>Model</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Analyzed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.page_id)}
|
||||
<tr
|
||||
class="clickable"
|
||||
onclick={() => onOpenDetail(r.page_id)}
|
||||
data-testid={`analysis-history-row-${r.page_id}`}
|
||||
title={r.error ?? ''}
|
||||
>
|
||||
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
||||
<td>
|
||||
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
||||
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
|
||||
</td>
|
||||
<td class="muted">{r.model ?? '—'}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td title={r.analyzed_at ? new Date(r.analyzed_at).toLocaleString() : ''}
|
||||
>{fmtAgo(r.analyzed_at)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="analysis-history-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
select,
|
||||
.search {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.nsfw {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 52rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.status-pill {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--font-xs);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-pill.done {
|
||||
background: color-mix(in srgb, #2e7d32 16%, transparent);
|
||||
color: #2e7d32;
|
||||
}
|
||||
.status-pill.failed {
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.nsfw-tag {
|
||||
font-size: var(--font-xs);
|
||||
color: #b85e1a;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
171
frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte
Normal file
171
frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import { getAnalysisMetrics, type AnalysisMetrics } from '$lib/api/admin';
|
||||
|
||||
let days = $state(7);
|
||||
let metrics = $state<AnalysisMetrics | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const successPct = $derived(
|
||||
metrics && metrics.n > 0 ? Math.round((metrics.ok / metrics.n) * 100) : null
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
metrics = await getAnalysisMetrics(days);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load metrics.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<section data-testid="analysis-metrics">
|
||||
<div class="winrow">
|
||||
<label>
|
||||
Window
|
||||
<select bind:value={days} onchange={load} data-testid="analysis-metrics-window">
|
||||
<option value={1}>24 hours</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={0}>All time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{:else if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if metrics}
|
||||
<div class="tiles">
|
||||
<div class="tile">
|
||||
<span class="label">Pages analyzed</span>
|
||||
<span class="value" data-testid="analysis-metrics-n">{metrics.n}</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Avg duration</span>
|
||||
<span class="value" data-testid="analysis-metrics-avg"
|
||||
>{fmtDuration(metrics.avg_ms)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Success</span>
|
||||
<span class="value">{successPct == null ? '—' : `${successPct}%`}</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Failed</span>
|
||||
<span class="value">{metrics.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>By model</h2>
|
||||
{#if metrics.by_model.length === 0}
|
||||
<p class="muted">No completed analyses in this window.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metrics.by_model as m (m.model)}
|
||||
<tr>
|
||||
<td>{m.model ?? '(unknown)'}</td>
|
||||
<td class="num">{fmtDuration(m.avg_ms)}</td>
|
||||
<td class="num">{m.n}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.winrow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
height: 32px;
|
||||
margin-left: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.tile .label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tile .value {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 32rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
293
frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
Normal file
293
frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
Normal file
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import {
|
||||
listCrawlerJobHistory,
|
||||
type CrawlerHistoryRow,
|
||||
type CrawlerJobState,
|
||||
type CrawlerJobKind,
|
||||
type RequeueScope
|
||||
} from '$lib/api/admin';
|
||||
|
||||
// Requeue is owned by the parent (shared with the dead-jobs table); we
|
||||
// call it then reload so the row's new state shows. `busy` disables the
|
||||
// inline button during a parent-driven action.
|
||||
let {
|
||||
onRequeue,
|
||||
busy = false
|
||||
}: {
|
||||
onRequeue: (scope: RequeueScope) => Promise<void>;
|
||||
busy?: boolean;
|
||||
} = $props();
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let rows = $state<CrawlerHistoryRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let stateFilter = $state<'' | CrawlerJobState>('');
|
||||
let kindFilter = $state<'' | CrawlerJobKind>('');
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let expanded = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const resp = await listCrawlerJobHistory({
|
||||
state: stateFilter || undefined,
|
||||
kind: kindFilter || undefined,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load history.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function applyFilters() {
|
||||
page = 1;
|
||||
load();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
load();
|
||||
}
|
||||
async function requeue(scope: RequeueScope) {
|
||||
await onRequeue(scope);
|
||||
await load();
|
||||
}
|
||||
|
||||
/** A human "Manga · Ch N · pP" target, or the source key / em-dash. */
|
||||
function target(r: CrawlerHistoryRow): string {
|
||||
if (r.manga_title) {
|
||||
let s = r.manga_title;
|
||||
if (r.chapter_number != null) s += ` · Ch ${r.chapter_number}`;
|
||||
if (r.page_number != null) s += ` · p${r.page_number}`;
|
||||
return s;
|
||||
}
|
||||
return r.source_key ?? '—';
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const secs = Math.round((Date.now() - then) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
if (secs < 90) return '1m ago';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const KINDS: { value: '' | CrawlerJobKind; label: string }[] = [
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'sync_manga', label: 'sync_manga' },
|
||||
{ value: 'sync_chapter_list', label: 'sync_chapter_list' },
|
||||
{ value: 'sync_chapter_content', label: 'sync_chapter' },
|
||||
{ value: 'analyze_page', label: 'analyze_page' }
|
||||
];
|
||||
const STATES: { value: '' | CrawlerJobState; label: string }[] = [
|
||||
{ value: '', label: 'All states' },
|
||||
{ value: 'done', label: 'done' },
|
||||
{ value: 'dead', label: 'dead' },
|
||||
{ value: 'running', label: 'running' },
|
||||
{ value: 'pending', label: 'pending' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="history" data-testid="crawler-history">
|
||||
<div class="toolbar">
|
||||
<select
|
||||
bind:value={stateFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by state"
|
||||
data-testid="crawler-history-state"
|
||||
>
|
||||
{#each STATES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={kindFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by type"
|
||||
data-testid="crawler-history-kind"
|
||||
>
|
||||
{#each KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}
|
||||
</select>
|
||||
<SearchBar
|
||||
bind:value={search}
|
||||
placeholder="Search manga / chapter…"
|
||||
onSearch={applyFilters}
|
||||
/>
|
||||
</div>
|
||||
<p class="muted note">
|
||||
Showing recent jobs — completed jobs are pruned after the retention
|
||||
window; dead jobs persist until requeued.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted" data-testid="crawler-history-loading">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="crawler-history-empty">No jobs match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Att.</th>
|
||||
<th>Updated</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.id)}
|
||||
<tr
|
||||
class:clickable={!!r.last_error}
|
||||
onclick={() =>
|
||||
r.last_error && (expanded = expanded === r.id ? null : r.id)}
|
||||
data-testid={`crawler-history-row-${r.id}`}
|
||||
>
|
||||
<td>
|
||||
<span class="badge state-{r.state}">{r.state}</span>
|
||||
</td>
|
||||
<td class="kind">{r.kind ?? '—'}</td>
|
||||
<td>{target(r)}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td>{r.attempts}/{r.max_attempts}</td>
|
||||
<td title={new Date(r.updated_at).toLocaleString()}
|
||||
>{fmtAgo(r.updated_at)}</td
|
||||
>
|
||||
<td class="actions">
|
||||
{#if r.state === 'dead'}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
requeue({ scope: 'job', job_id: r.id });
|
||||
}}
|
||||
data-testid={`crawler-history-requeue-${r.id}`}
|
||||
>
|
||||
⤴ Requeue
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{#if expanded === r.id && r.last_error}
|
||||
<tr class="errrow">
|
||||
<td colspan="7"><code>{r.last_error}</code></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="crawler-history-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
select {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.note {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
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);
|
||||
}
|
||||
.kind {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.actions {
|
||||
text-align: right;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow td {
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.errrow code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
/* State dots reuse the page's shared badge palette. */
|
||||
.badge {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.state-done {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #86efac;
|
||||
}
|
||||
.state-dead {
|
||||
background: color-mix(in srgb, var(--danger, #dc2626) 16%, transparent);
|
||||
color: var(--danger, #dc2626);
|
||||
border-color: color-mix(in srgb, var(--danger, #dc2626) 45%, transparent);
|
||||
}
|
||||
.state-running,
|
||||
.state-pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
</style>
|
||||
330
frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte
Normal file
330
frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte
Normal file
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import {
|
||||
getCrawlerMetrics,
|
||||
listCrawlerOps,
|
||||
type OpSummary,
|
||||
type OpRow,
|
||||
type CrawlOp
|
||||
} from '$lib/api/admin';
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let days = $state(7);
|
||||
let summary = $state<OpSummary[]>([]);
|
||||
let summaryLoading = $state(true);
|
||||
|
||||
let rows = $state<OpRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let opFilter = $state<'' | CrawlOp>('');
|
||||
let outcomeFilter = $state<'' | 'ok' | 'failed'>('');
|
||||
let opsLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let expanded = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
// Human labels + canonical order for the summary table.
|
||||
const OP_LABELS: Record<CrawlOp, string> = {
|
||||
manga_list: 'manga list walk',
|
||||
manga_detail: 'manga detail',
|
||||
manga_cover: 'manga cover',
|
||||
chapter: 'whole chapter'
|
||||
};
|
||||
const OP_ORDER: CrawlOp[] = ['manga_list', 'manga_detail', 'manga_cover', 'chapter'];
|
||||
const orderedSummary = $derived(
|
||||
OP_ORDER.map((op) => summary.find((s) => s.op === op)).filter(
|
||||
(s): s is OpSummary => s != null
|
||||
)
|
||||
);
|
||||
// Derived per-page crawl time from the chapter roll-up (Σms ÷ Σpages ≈
|
||||
// avg_ms ÷ avg_items). Shown as an indented sub-row under "whole chapter".
|
||||
const chapter = $derived(summary.find((s) => s.op === 'chapter'));
|
||||
const perPageMs = $derived(
|
||||
chapter && chapter.avg_ms != null && chapter.avg_items && chapter.avg_items > 0
|
||||
? chapter.avg_ms / chapter.avg_items
|
||||
: null
|
||||
);
|
||||
|
||||
function successPct(s: OpSummary): string {
|
||||
if (s.n === 0) return '—';
|
||||
return `${Math.round((s.ok / s.n) * 100)}%`;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
summaryLoading = true;
|
||||
try {
|
||||
summary = (await getCrawlerMetrics(days)).summary;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load metrics.';
|
||||
} finally {
|
||||
summaryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOps() {
|
||||
opsLoading = true;
|
||||
try {
|
||||
const resp = await listCrawlerOps({
|
||||
op: opFilter || undefined,
|
||||
outcome: outcomeFilter || undefined,
|
||||
days,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load operations.';
|
||||
} finally {
|
||||
opsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSummary();
|
||||
loadOps();
|
||||
});
|
||||
|
||||
function onWindowChange() {
|
||||
page = 1;
|
||||
loadSummary();
|
||||
loadOps();
|
||||
}
|
||||
function onOpsFilter() {
|
||||
page = 1;
|
||||
loadOps();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
loadOps();
|
||||
}
|
||||
|
||||
function opLabel(op: CrawlOp): string {
|
||||
return OP_LABELS[op] ?? op;
|
||||
}
|
||||
function fmtAgo(iso: string): string {
|
||||
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section data-testid="crawler-metrics">
|
||||
<div class="winrow">
|
||||
<label>
|
||||
Window
|
||||
<select
|
||||
bind:value={days}
|
||||
onchange={onWindowChange}
|
||||
data-testid="crawler-metrics-window"
|
||||
>
|
||||
<option value={1}>24 hours</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={0}>All time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<h2>Average durations by type</h2>
|
||||
{#if summaryLoading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if orderedSummary.length === 0}
|
||||
<p class="muted" data-testid="crawler-metrics-empty">No operations recorded yet.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
<th class="num">OK / Fail</th>
|
||||
<th class="num">Success</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each orderedSummary as s (s.op)}
|
||||
<tr data-testid={`crawler-metrics-row-${s.op}`}>
|
||||
<td>{opLabel(s.op)}</td>
|
||||
<td class="num">{fmtDuration(s.avg_ms)}</td>
|
||||
<td class="num">{s.n}</td>
|
||||
<td class="num">{s.ok} / {s.failed}</td>
|
||||
<td class="num">{successPct(s)}</td>
|
||||
</tr>
|
||||
{#if s.op === 'chapter' && perPageMs != null}
|
||||
<tr class="derived" data-testid="crawler-metrics-perpage">
|
||||
<td>└ per page (≈)</td>
|
||||
<td class="num">{fmtDuration(perPageMs)}</td>
|
||||
<td class="num" colspan="3"
|
||||
>≈{Math.round(chapter?.avg_items ?? 0)} pages/chapter</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<h2>Recent operations</h2>
|
||||
<div class="toolbar">
|
||||
<select bind:value={opFilter} onchange={onOpsFilter} aria-label="Filter by type">
|
||||
<option value="">All types</option>
|
||||
{#each OP_ORDER as op (op)}<option value={op}>{opLabel(op)}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={outcomeFilter}
|
||||
onchange={onOpsFilter}
|
||||
aria-label="Filter by outcome"
|
||||
>
|
||||
<option value="">All outcomes</option>
|
||||
<option value="ok">ok</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if opsLoading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="crawler-metrics-ops-empty">No operations match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Outcome</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.id)}
|
||||
<tr
|
||||
class:clickable={!!r.error}
|
||||
onclick={() => r.error && (expanded = expanded === r.id ? null : r.id)}
|
||||
data-testid={`crawler-op-${r.id}`}
|
||||
>
|
||||
<td>{opLabel(r.op)}</td>
|
||||
<td>
|
||||
{r.manga_title
|
||||
? `${r.manga_title}${r.chapter_number != null ? ` · Ch ${r.chapter_number}` : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td>
|
||||
<span class="dot {r.outcome}"></span>{r.outcome}
|
||||
</td>
|
||||
<td title={new Date(r.finished_at).toLocaleString()}
|
||||
>{fmtAgo(r.finished_at)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if expanded === r.id && r.error}
|
||||
<tr class="errrow"><td colspan="5"><code>{r.error}</code></td></tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="crawler-metrics-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.winrow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
height: 32px;
|
||||
margin-left: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
h2 {
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 48rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.derived td {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow td {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.ok {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.dot.failed {
|
||||
background: var(--danger);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
29
frontend/src/lib/format.test.ts
Normal file
29
frontend/src/lib/format.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtDuration } from './format';
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
it('renders sub-second as ms', () => {
|
||||
expect(fmtDuration(0)).toBe('0ms');
|
||||
expect(fmtDuration(420)).toBe('420ms');
|
||||
expect(fmtDuration(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('renders seconds with one decimal under 10s, none above', () => {
|
||||
expect(fmtDuration(6100)).toBe('6.1s');
|
||||
expect(fmtDuration(42000)).toBe('42s');
|
||||
});
|
||||
|
||||
it('renders minutes + zero-padded seconds', () => {
|
||||
expect(fmtDuration(62000)).toBe('1m 02s');
|
||||
expect(fmtDuration(125000)).toBe('2m 05s');
|
||||
});
|
||||
|
||||
it('rounds the seconds carry into minutes (no "1m 60s")', () => {
|
||||
expect(fmtDuration(119600)).toBe('2m 00s');
|
||||
});
|
||||
|
||||
it('renders null/undefined as an em-dash', () => {
|
||||
expect(fmtDuration(null)).toBe('—');
|
||||
expect(fmtDuration(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/format.ts
Normal file
14
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Human-readable duration from milliseconds.
|
||||
* `420 → "420ms"`, `6100 → "6.1s"`, `62000 → "1m 02s"`, `null → "—"`. */
|
||||
export function fmtDuration(ms: number | null | undefined): string {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
const secs = ms / 1000;
|
||||
if (secs < 60) return `${secs.toFixed(secs < 10 ? 1 : 0)}s`;
|
||||
// Round to whole seconds *before* splitting so e.g. 119_600ms renders
|
||||
// "2m 00s", not "1m 60s".
|
||||
const whole = Math.round(secs);
|
||||
const mins = Math.floor(whole / 60);
|
||||
const rem = whole % 60;
|
||||
return `${mins}m ${String(rem).padStart(2, '0')}s`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
reenqueueAnalysis,
|
||||
@@ -19,9 +19,16 @@
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import AnalysisHistoryTable from '$lib/components/analysis/AnalysisHistoryTable.svelte';
|
||||
import AnalysisMetricsPanel from '$lib/components/analysis/AnalysisMetricsPanel.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
// Segmented Live/History/Metrics view. Live = the SSE coverage
|
||||
// dashboard; History = a searchable log; Metrics = durations/averages.
|
||||
let view = $state<'live' | 'history' | 'metrics'>('live');
|
||||
|
||||
// Global enqueue toggle.
|
||||
let includeAnalyzed = $state(false);
|
||||
|
||||
@@ -65,6 +72,44 @@
|
||||
let source: EventSource | null = null;
|
||||
let sseErrors = 0;
|
||||
|
||||
// Global "now analyzing" pointer, driven by the `started` SSE event so
|
||||
// the operator sees what the worker is on even when nothing is
|
||||
// expanded. `phase` flips to done/failed for a moment on the matching
|
||||
// completed/failed event, then the banner clears.
|
||||
type NowAnalyzing = {
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
chapter_id: string;
|
||||
chapter_number: number;
|
||||
page_id: string;
|
||||
page_number: number;
|
||||
phase: 'analyzing' | 'done' | 'failed';
|
||||
};
|
||||
let nowAnalyzing = $state<NowAnalyzing | null>(null);
|
||||
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleClear(pageId: string) {
|
||||
if (clearTimer) clearTimeout(clearTimer);
|
||||
clearTimer = setTimeout(() => {
|
||||
// Don't clear if a newer page took over the banner.
|
||||
if (nowAnalyzing?.page_id === pageId) nowAnalyzing = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/** Switch to Live, expand the now-analyzing chapter, scroll its chip. */
|
||||
async function jumpToNowAnalyzing() {
|
||||
const t = nowAnalyzing;
|
||||
if (!t) return;
|
||||
view = 'live';
|
||||
await tick();
|
||||
if (expandedManga !== t.manga_id) await toggleManga(t.manga_id);
|
||||
if (expandedChapter !== t.chapter_id) await toggleChapter(t.chapter_id);
|
||||
await tick();
|
||||
document
|
||||
.querySelector(`[data-testid="admin-analysis-page-${t.page_id}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadCoverage(true);
|
||||
openStream();
|
||||
@@ -73,6 +118,7 @@
|
||||
onDestroy(() => {
|
||||
source?.close();
|
||||
source = null;
|
||||
if (clearTimer) clearTimeout(clearTimer);
|
||||
});
|
||||
|
||||
function pushTicker(text: string) {
|
||||
@@ -155,16 +201,34 @@
|
||||
}
|
||||
if (ev.kind === 'started') {
|
||||
liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
|
||||
pushTicker(`Analyzing page ${ev.page_number}`);
|
||||
if (clearTimer) clearTimeout(clearTimer);
|
||||
nowAnalyzing = {
|
||||
manga_id: ev.manga_id,
|
||||
manga_title: ev.manga_title,
|
||||
chapter_id: ev.chapter_id,
|
||||
chapter_number: ev.chapter_number,
|
||||
page_id: ev.page_id,
|
||||
page_number: ev.page_number,
|
||||
phase: 'analyzing'
|
||||
};
|
||||
pushTicker(`Analyzing ${ev.manga_title} · p${ev.page_number}`);
|
||||
} else if (ev.kind === 'completed') {
|
||||
liveStatus = { ...liveStatus, [ev.page_id]: 'done' };
|
||||
if (!counted.has(ev.page_id)) {
|
||||
counted.add(ev.page_id);
|
||||
bumpCoverage(ev.manga_id, ev.chapter_id);
|
||||
}
|
||||
if (nowAnalyzing?.page_id === ev.page_id) {
|
||||
nowAnalyzing = { ...nowAnalyzing, phase: 'done' };
|
||||
scheduleClear(ev.page_id);
|
||||
}
|
||||
pushTicker(`✓ Analyzed page ${ev.page_number}`);
|
||||
} else if (ev.kind === 'failed') {
|
||||
liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
|
||||
if (nowAnalyzing?.page_id === ev.page_id) {
|
||||
nowAnalyzing = { ...nowAnalyzing, phase: 'failed' };
|
||||
scheduleClear(ev.page_id);
|
||||
}
|
||||
pushTicker(`✗ Failed page ${ev.page_number}`);
|
||||
}
|
||||
}
|
||||
@@ -393,6 +457,60 @@
|
||||
page's result. Updates stream live as pages are queued and processed.
|
||||
</p>
|
||||
|
||||
<div class="viewtabs">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ label: 'Live', value: 'live' },
|
||||
{ label: 'History', value: 'history' },
|
||||
{ label: 'Metrics', value: 'metrics' }
|
||||
]}
|
||||
value={view}
|
||||
onchange={(v) => (view = v)}
|
||||
ariaLabel="Analysis view"
|
||||
testid="admin-analysis-tab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if nowAnalyzing}
|
||||
<div
|
||||
class="now-analyzing {nowAnalyzing.phase}"
|
||||
data-testid="admin-analysis-now"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="now-icon">
|
||||
{nowAnalyzing.phase === 'done' ? '✓' : nowAnalyzing.phase === 'failed' ? '✗' : '⟳'}
|
||||
</span>
|
||||
<span class="now-label">
|
||||
<strong
|
||||
>{nowAnalyzing.phase === 'done'
|
||||
? 'Analyzed'
|
||||
: nowAnalyzing.phase === 'failed'
|
||||
? 'Failed'
|
||||
: 'Now analyzing'}</strong
|
||||
>
|
||||
{nowAnalyzing.manga_title} · Ch {nowAnalyzing.chapter_number} · Page
|
||||
{nowAnalyzing.page_number}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="now-jump"
|
||||
onclick={jumpToNowAnalyzing}
|
||||
data-testid="admin-analysis-now-jump"
|
||||
>
|
||||
Jump →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if view === 'history'}
|
||||
<AnalysisHistoryTable onOpenDetail={openDetail} />
|
||||
{/if}
|
||||
|
||||
{#if view === 'metrics'}
|
||||
<AnalysisMetricsPanel />
|
||||
{/if}
|
||||
|
||||
{#if view === 'live'}
|
||||
{#if ticker.length > 0}
|
||||
<ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
|
||||
{#each ticker as t (t.id)}
|
||||
@@ -586,6 +704,7 @@
|
||||
{#if error}
|
||||
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
open={detailOpen}
|
||||
@@ -708,6 +827,58 @@
|
||||
.live-pill.on .live-dot {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.viewtabs {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.now-analyzing {
|
||||
position: sticky;
|
||||
top: var(--space-2);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, #d4762a 45%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, #d4762a 12%, transparent);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.now-analyzing.done {
|
||||
border-color: color-mix(in srgb, #2e7d32 45%, transparent);
|
||||
background: color-mix(in srgb, #2e7d32 12%, transparent);
|
||||
}
|
||||
.now-analyzing.failed {
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
.now-icon {
|
||||
font-size: var(--font-lg);
|
||||
color: #b85e1a;
|
||||
}
|
||||
.now-analyzing.analyzing .now-icon {
|
||||
animation: spin 1.4s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
.now-analyzing.done .now-icon {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.now-analyzing.failed .now-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
.now-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.now-jump {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.ticker {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-3);
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import ActiveJobsTable from '$lib/components/crawler/ActiveJobsTable.svelte';
|
||||
import MissingCoversTable from '$lib/components/crawler/MissingCoversTable.svelte';
|
||||
import DeadJobsTable from '$lib/components/crawler/DeadJobsTable.svelte';
|
||||
import CrawlerHistoryTable from '$lib/components/crawler/CrawlerHistoryTable.svelte';
|
||||
import CrawlerMetricsPanel from '$lib/components/crawler/CrawlerMetricsPanel.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import SessionModal from '$lib/components/crawler/SessionModal.svelte';
|
||||
import RestartConfirmModal from '$lib/components/crawler/RestartConfirmModal.svelte';
|
||||
import RequeueAllConfirmModal from '$lib/components/crawler/RequeueAllConfirmModal.svelte';
|
||||
@@ -27,6 +30,10 @@
|
||||
type RequeueScope
|
||||
} from '$lib/api/admin';
|
||||
|
||||
// Segmented Live/History/Metrics view. Live keeps the SSE-driven
|
||||
// dashboard; History is a searchable job log; Metrics shows durations.
|
||||
let view = $state<'live' | 'history' | 'metrics'>('live');
|
||||
|
||||
let status: CrawlerStatus | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let notice: string | null = $state(null);
|
||||
@@ -387,6 +394,20 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="viewtabs">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ label: 'Live', value: 'live' },
|
||||
{ label: 'History', value: 'history' },
|
||||
{ label: 'Metrics', value: 'metrics' }
|
||||
]}
|
||||
value={view}
|
||||
onchange={(v) => (view = v)}
|
||||
ariaLabel="Crawler view"
|
||||
testid="crawler-tab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
@@ -394,6 +415,11 @@
|
||||
<p class="notice" role="status">{notice}</p>
|
||||
{/if}
|
||||
|
||||
{#if view === 'history'}
|
||||
<CrawlerHistoryTable onRequeue={requeue} {busy} />
|
||||
{:else if view === 'metrics'}
|
||||
<CrawlerMetricsPanel />
|
||||
{:else}
|
||||
{#if status}
|
||||
<CrawlerHero {status} />
|
||||
|
||||
@@ -446,6 +472,7 @@
|
||||
onRequeue={requeue}
|
||||
onRequeueAll={() => (requeueAllModalOpen = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RestartConfirmModal
|
||||
open={restartModalOpen}
|
||||
@@ -488,6 +515,9 @@
|
||||
.livedot.on {
|
||||
color: var(--success, #0a7d2c);
|
||||
}
|
||||
.viewtabs {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.notice {
|
||||
color: var(--success, #0a7d2c);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
Reference in New Issue
Block a user