feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped

This commit was merged in pull request #6.
This commit is contained in:
2026-06-16 12:21:13 +00:00
parent 790549636f
commit d51ab2a049
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);

View 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');
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.82.0",
"version": "0.84.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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');
});
});

View File

@@ -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;
};

View 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>

View 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>

View 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>

View 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>

View 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('—');
});
});

View 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`;
}

View File

@@ -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);

View File

@@ -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);