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

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