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

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