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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user