New /admin/health rolls up signals already collected — disk/memory sensors, dead-job + missing-cover backlogs, 24h crawl/analysis failure rates, metadata cron freshness, and the live crawler session/browser flags — into a flat checks list (ok/warn/critical) with an overall status and remediation links. The overview gains a compact Health summary banner linking in. GET /v1/admin/health evaluates the checks (DB sub-queries run concurrently via try_join; all hit existing indexes). PUT /v1/admin/health/thresholds persists the thresholds to app_settings (merged over compiled defaults, forward-compat via serde(default)), validates ranges (400 on bad input), and audit-logs the change in one transaction. New repo::crawler::last_metadata_tick_at getter and a lightweight system::memory_percent_used (no CPU settle delay). Pure status helpers (over_limit for gauges, exceeds for backlog counts so a 0-limit doesn't false-alarm at 0) are unit-tested; endpoint tests cover shape, gating, persistence+audit, and validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1064 lines
33 KiB
TypeScript
1064 lines
33 KiB
TypeScript
// Admin-only API client. Every endpoint here is guarded by
|
|
// RequireAdmin on the backend (session cookie only — bearer tokens
|
|
// won't reach these routes). 403s thrown here propagate up to the
|
|
// /admin layout, which renders the framework error page.
|
|
|
|
import { request, apiUrl, type Page } from './client';
|
|
import type { User } from './auth';
|
|
import type { MangaDetail } from './mangas';
|
|
import type { Chapter } from './chapters';
|
|
import type { ContentWarning } from './page_tags';
|
|
|
|
// ---- users -----------------------------------------------------------------
|
|
|
|
export type AdminUsersPage = {
|
|
items: User[];
|
|
page: Page;
|
|
};
|
|
|
|
export type ListAdminUsersOptions = {
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
export async function listAdminUsers(
|
|
opts: ListAdminUsersOptions = {}
|
|
): Promise<AdminUsersPage> {
|
|
const params = new URLSearchParams();
|
|
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<AdminUsersPage>(`/v1/admin/users${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export async function deleteAdminUser(id: string): Promise<void> {
|
|
await request<void>(`/v1/admin/users/${encodeURIComponent(id)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User> {
|
|
return request<User>(`/v1/admin/users/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ is_admin: isAdmin })
|
|
});
|
|
}
|
|
|
|
export type CreateAdminUserInput = {
|
|
username: string;
|
|
password: string;
|
|
is_admin?: boolean;
|
|
};
|
|
|
|
/** POST /v1/admin/users — admin-initiated account creation. Works
|
|
* regardless of the ALLOW_SELF_REGISTER toggle, since the entire
|
|
* point is for an admin to enroll someone when self-register is off. */
|
|
export async function createAdminUser(input: CreateAdminUserInput): Promise<User> {
|
|
return request<User>('/v1/admin/users', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(input)
|
|
});
|
|
}
|
|
|
|
// ---- mangas / chapters with sync state -------------------------------------
|
|
|
|
export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';
|
|
|
|
export type AdminMangaRow = {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
cover_image_path: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
sync_state: MangaSyncState;
|
|
chapter_count: number;
|
|
latest_seen_at: string | null;
|
|
};
|
|
|
|
export type AdminMangasPage = {
|
|
items: AdminMangaRow[];
|
|
page: Page;
|
|
};
|
|
|
|
export type ListAdminMangasOptions = {
|
|
search?: string;
|
|
syncState?: MangaSyncState;
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
export async function listAdminMangas(
|
|
opts: ListAdminMangasOptions = {}
|
|
): Promise<AdminMangasPage> {
|
|
const params = new URLSearchParams();
|
|
if (opts.search) params.set('search', opts.search);
|
|
if (opts.syncState) params.set('sync_state', opts.syncState);
|
|
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<AdminMangasPage>(`/v1/admin/mangas${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export type ChapterSyncState =
|
|
| 'downloading'
|
|
| 'dropped'
|
|
| 'failed'
|
|
| 'not_downloaded'
|
|
| 'synced';
|
|
|
|
export type AdminChapterRow = {
|
|
id: string;
|
|
manga_id: string;
|
|
number: number;
|
|
title: string | null;
|
|
page_count: number;
|
|
created_at: string;
|
|
sync_state: ChapterSyncState;
|
|
latest_seen_at: string | null;
|
|
};
|
|
|
|
export type AdminChaptersPage = {
|
|
items: AdminChapterRow[];
|
|
page: Page;
|
|
};
|
|
|
|
export type ListAdminChaptersOptions = {
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
export async function listAdminChapters(
|
|
mangaId: string,
|
|
opts: ListAdminChaptersOptions = {}
|
|
): Promise<AdminChaptersPage> {
|
|
const params = new URLSearchParams();
|
|
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<AdminChaptersPage>(
|
|
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}`
|
|
);
|
|
}
|
|
|
|
// ---- system ----------------------------------------------------------------
|
|
|
|
export type DiskStats = {
|
|
total_bytes: number;
|
|
used_bytes: number;
|
|
free_bytes: number;
|
|
percent_used: number;
|
|
};
|
|
|
|
export type MemoryStats = {
|
|
total_bytes: number;
|
|
used_bytes: number;
|
|
percent_used: number;
|
|
};
|
|
|
|
export type LoadAvg = {
|
|
one: number;
|
|
five: number;
|
|
fifteen: number;
|
|
};
|
|
|
|
export type CpuStats = {
|
|
percent_used: number;
|
|
load_avg: LoadAvg;
|
|
/** Per-core usage percentages, one entry per logical core. */
|
|
per_core: number[];
|
|
};
|
|
|
|
export type TempStat = {
|
|
label: string;
|
|
celsius: number;
|
|
max_celsius: number | null;
|
|
critical_celsius: number | null;
|
|
};
|
|
|
|
export type Alert = {
|
|
level: 'warning';
|
|
message: string;
|
|
};
|
|
|
|
export type SystemStats = {
|
|
disk: DiskStats | null;
|
|
memory: MemoryStats;
|
|
cpu: CpuStats;
|
|
/** Hardware temperature sensors. Empty when none are exposed (e.g.
|
|
* inside a container without `/sys` access). */
|
|
temperatures: TempStat[];
|
|
alerts: Alert[];
|
|
};
|
|
|
|
export async function getSystemStats(): Promise<SystemStats> {
|
|
return request<SystemStats>('/v1/admin/system');
|
|
}
|
|
|
|
// ---- overview aggregates ---------------------------------------------------
|
|
|
|
export type UsersOverview = {
|
|
total: number;
|
|
admins: number;
|
|
newest_username: string | null;
|
|
newest_created_at: string | null;
|
|
};
|
|
|
|
export type MangasOverview = {
|
|
total: number;
|
|
synced: number;
|
|
in_progress: number;
|
|
dropped: number;
|
|
total_chapters: number;
|
|
total_pages: number;
|
|
newest_title: string | null;
|
|
newest_seen_at: string | null;
|
|
};
|
|
|
|
export type AnalysisOverview = {
|
|
analyzed_pages: number;
|
|
total_pages: number;
|
|
};
|
|
|
|
export type OverviewStats = {
|
|
users: UsersOverview;
|
|
mangas: MangasOverview;
|
|
analysis: AnalysisOverview;
|
|
};
|
|
|
|
export async function getOverviewStats(): Promise<OverviewStats> {
|
|
return request<OverviewStats>('/v1/admin/overview');
|
|
}
|
|
|
|
// ---- storage usage ---------------------------------------------------------
|
|
|
|
export type TopManga = {
|
|
id: string;
|
|
title: string;
|
|
total_bytes: number;
|
|
};
|
|
|
|
export type TopChapter = {
|
|
id: string;
|
|
manga_id: string;
|
|
number: number;
|
|
title: string | null;
|
|
total_bytes: number;
|
|
};
|
|
|
|
export type StorageStats = {
|
|
total_bytes: number;
|
|
covers_bytes: number;
|
|
chapters_bytes: number;
|
|
disk_total_bytes: number | null;
|
|
ratio_of_disk: number | null;
|
|
avg_image_bytes: number | null;
|
|
avg_cover_bytes: number | null;
|
|
avg_chapter_page_bytes: number | null;
|
|
top_mangas: TopManga[];
|
|
top_chapters: TopChapter[];
|
|
/** Pages still awaiting a size backfill. When > 0 the figures above are
|
|
* partial and the leaderboards omit not-yet-measured content. */
|
|
unmeasured_pages: number;
|
|
/** Cover blobs still awaiting a size backfill. */
|
|
unmeasured_covers: number;
|
|
};
|
|
|
|
export type StorageBackfillResult = {
|
|
pages: number;
|
|
covers: number;
|
|
/** Blobs storage reports as absent (orphaned). */
|
|
missing: number;
|
|
/** Blobs that errored for another reason (bad key, transient IO) — retryable. */
|
|
errored: number;
|
|
/** `true` when a per-run cap was hit before the backlog drained; run again. */
|
|
more_remaining: boolean;
|
|
};
|
|
|
|
export async function getStorageStats(): Promise<StorageStats> {
|
|
return request<StorageStats>('/v1/admin/storage');
|
|
}
|
|
|
|
export async function backfillStorage(): Promise<StorageBackfillResult> {
|
|
return request<StorageBackfillResult>('/v1/admin/storage/backfill', {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
// ---- force resync ----------------------------------------------------------
|
|
|
|
export type MangaResyncResponse = {
|
|
manga: MangaDetail;
|
|
metadata_status: 'new' | 'updated' | 'unchanged';
|
|
cover_fetched: boolean;
|
|
};
|
|
|
|
export type ChapterResyncResponse = {
|
|
chapter: Chapter;
|
|
outcome: 'fetched' | 'skipped';
|
|
/** Page count when `outcome === 'fetched'`; null when skipped. */
|
|
pages: number | null;
|
|
};
|
|
|
|
/** POST /v1/admin/mangas/:id/resync — refetches metadata + cover from
|
|
* the manga's live crawler source. Long-running (one HTTP request per
|
|
* Chromium nav + image download), so the UI should disable the trigger
|
|
* and surface progress. */
|
|
export async function resyncManga(id: string): Promise<MangaResyncResponse> {
|
|
return request<MangaResyncResponse>(
|
|
`/v1/admin/mangas/${encodeURIComponent(id)}/resync`,
|
|
{ method: 'POST' }
|
|
);
|
|
}
|
|
|
|
/** POST /v1/admin/chapters/:id/resync — force-refetches a chapter's
|
|
* pages even if `page_count > 0`. Same long-running caveat as
|
|
* `resyncManga`. */
|
|
export async function resyncChapter(id: string): Promise<ChapterResyncResponse> {
|
|
return request<ChapterResyncResponse>(
|
|
`/v1/admin/chapters/${encodeURIComponent(id)}/resync`,
|
|
{ method: 'POST' }
|
|
);
|
|
}
|
|
|
|
// ---- crawler observability + control ---------------------------------------
|
|
|
|
/** Current daemon activity. Discriminated on `state`. */
|
|
export type CrawlerPhase =
|
|
| { state: 'idle'; next_fire: string | null }
|
|
| { state: 'walking_list' }
|
|
| { state: 'fetching_metadata'; index: number; total: number | null; title: string }
|
|
| { state: 'cover_backfill'; index: number; total: number }
|
|
| { state: 'reconciling'; walked: number; enqueued: number };
|
|
|
|
/** A chapter being crawled right now, with a live page count. */
|
|
export type ActiveChapter = {
|
|
manga_id: string;
|
|
manga_title: string;
|
|
chapter_id: string;
|
|
chapter_number: number;
|
|
pages_done: number;
|
|
pages_total: number | null;
|
|
};
|
|
|
|
export type CrawlerLastPass = {
|
|
at: string | null;
|
|
discovered: number;
|
|
upserted: number;
|
|
covers_fetched: number;
|
|
mangas_failed: number;
|
|
};
|
|
|
|
export type CrawlerStatus = {
|
|
daemon: 'running' | 'disabled';
|
|
phase: CrawlerPhase | null;
|
|
worker_count: number;
|
|
active_chapters: ActiveChapter[];
|
|
current_cover: { manga_id: string; manga_title: string } | null;
|
|
covers_queued: number;
|
|
last_pass: CrawlerLastPass;
|
|
session: { expired: boolean; configured: boolean };
|
|
browser: 'healthy' | 'draining' | 'restarting' | 'down';
|
|
queue: { pending: number; running: number; dead: number };
|
|
};
|
|
|
|
export async function getCrawlerStatus(): Promise<CrawlerStatus> {
|
|
return request<CrawlerStatus>('/v1/admin/crawler');
|
|
}
|
|
|
|
/** URL of the Server-Sent Events live-status stream. Open with
|
|
* `new EventSource(...)` while the crawler page is mounted and close it on
|
|
* navigate-away so the subscription is scoped to the active page. Each
|
|
* message is a named `status` event whose `data` is a {@link CrawlerStatus}. */
|
|
export function crawlerStatusStreamUrl(): string {
|
|
return apiUrl('/v1/admin/crawler/stream');
|
|
}
|
|
|
|
/** POST /v1/admin/crawler/run — trigger an out-of-cycle metadata pass. */
|
|
export async function runCrawlerPass(): Promise<{ started: boolean }> {
|
|
return request('/v1/admin/crawler/run', { method: 'POST' });
|
|
}
|
|
|
|
/** POST /v1/admin/crawler/reconcile — full list-only walk that enqueues
|
|
* mangas missing from the DB as sync_manga jobs. */
|
|
export async function reconcileCrawler(): Promise<{ started: boolean }> {
|
|
return request('/v1/admin/crawler/reconcile', { method: 'POST' });
|
|
}
|
|
|
|
/** POST /v1/admin/crawler/browser/restart — coordinated Chromium restart. */
|
|
export async function restartCrawlerBrowser(): Promise<{ ok: boolean; error: string | null }> {
|
|
return request('/v1/admin/crawler/browser/restart', { method: 'POST' });
|
|
}
|
|
|
|
/** POST /v1/admin/crawler/session — refresh PHPSESSID and re-probe. */
|
|
export async function updateCrawlerSession(
|
|
phpsessid: string
|
|
): Promise<{ valid: boolean; error: string | null }> {
|
|
return request('/v1/admin/crawler/session', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ phpsessid })
|
|
});
|
|
}
|
|
|
|
/** POST /v1/admin/crawler/session/clear-expired — resume idled workers. */
|
|
export async function clearCrawlerSessionExpired(): Promise<{ cleared: boolean }> {
|
|
return request('/v1/admin/crawler/session/clear-expired', { method: 'POST' });
|
|
}
|
|
|
|
export type DeadJob = {
|
|
id: string;
|
|
kind: string;
|
|
chapter_id: string | null;
|
|
manga_id: string | null;
|
|
manga_title: string | null;
|
|
chapter_number: number | null;
|
|
/** Payload title (fallback label for sync_manga jobs with no manga row). */
|
|
payload_title: string | null;
|
|
/** Source detail URL from the payload (sync_manga). */
|
|
source_url: string | null;
|
|
/** Source-native key from the payload (sync_manga). */
|
|
source_key: string | null;
|
|
attempts: number;
|
|
max_attempts: number;
|
|
last_error: string | null;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type DeadJobsPage = { items: DeadJob[]; page: Page };
|
|
|
|
export async function listDeadJobs(
|
|
opts?: {
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
},
|
|
init?: RequestInit
|
|
): Promise<DeadJobsPage> {
|
|
const params = new URLSearchParams();
|
|
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<DeadJobsPage>(
|
|
`/v1/admin/crawler/dead-jobs${qs ? `?${qs}` : ''}`,
|
|
init
|
|
);
|
|
}
|
|
|
|
/** Requeue scope: all dead jobs, one manga's, one chapter's, or a single job.
|
|
* `scope: 'all'` requires an explicit `confirm: true` so a careless
|
|
* click (or CSRF bait) can't flip the entire dead pile. */
|
|
export type RequeueScope =
|
|
| { scope: 'all'; confirm: true }
|
|
| { scope: 'manga'; manga_id: string }
|
|
| { scope: 'chapter'; chapter_id: string }
|
|
| { scope: 'job'; job_id: string };
|
|
|
|
export async function requeueDeadJobs(scope: RequeueScope): Promise<{ requeued: number }> {
|
|
return request('/v1/admin/crawler/dead-jobs/requeue', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(scope)
|
|
});
|
|
}
|
|
|
|
/** A queued/running chapter-content job (which chapters are queued). */
|
|
export type ActiveJob = {
|
|
id: string;
|
|
chapter_id: string | null;
|
|
manga_id: string | null;
|
|
manga_title: string | null;
|
|
chapter_number: number | null;
|
|
state: 'pending' | 'running';
|
|
attempts: number;
|
|
max_attempts: number;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type ActiveJobsPage = { items: ActiveJob[]; page: Page };
|
|
|
|
/** GET /v1/admin/crawler/active-jobs — which chapters of which mangas are
|
|
* queued or running now. */
|
|
export async function listActiveJobs(
|
|
opts?: {
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
},
|
|
init?: RequestInit
|
|
): Promise<ActiveJobsPage> {
|
|
const params = new URLSearchParams();
|
|
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<ActiveJobsPage>(
|
|
`/v1/admin/crawler/active-jobs${qs ? `?${qs}` : ''}`,
|
|
init
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
/** Payload title (fallback label for sync_manga jobs with no manga row). */
|
|
payload_title: string | null;
|
|
/** Source detail URL from the payload (sync_manga). */
|
|
source_url: 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 };
|
|
|
|
/** GET /v1/admin/crawler/covers — which manga covers are queued. */
|
|
export async function listMissingCovers(
|
|
opts?: {
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
},
|
|
init?: RequestInit
|
|
): Promise<MissingCoversPage> {
|
|
const params = new URLSearchParams();
|
|
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<MissingCoversPage>(
|
|
`/v1/admin/crawler/covers${qs ? `?${qs}` : ''}`,
|
|
init
|
|
);
|
|
}
|
|
|
|
// ---- AI content analysis ---------------------------------------------------
|
|
|
|
/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId`
|
|
* are mutually exclusive; omit both to target the whole library. */
|
|
export type ReenqueueAnalysisOptions = {
|
|
/** Skip pages that already have a completed analysis (default true).
|
|
* When false, in-scope pages are force re-analyzed. */
|
|
onlyUnanalyzed?: boolean;
|
|
mangaId?: string;
|
|
chapterId?: string;
|
|
};
|
|
|
|
/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's
|
|
* pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED
|
|
* on the backend (503 otherwise). */
|
|
export async function reenqueueAnalysis(
|
|
opts: ReenqueueAnalysisOptions = {}
|
|
): Promise<{ enqueued: number }> {
|
|
const body: Record<string, unknown> = {
|
|
only_unanalyzed: opts.onlyUnanalyzed ?? true
|
|
};
|
|
if (opts.mangaId) body.manga_id = opts.mangaId;
|
|
if (opts.chapterId) body.chapter_id = opts.chapterId;
|
|
return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
/** Force re-analysis of a single page (used by the reader context menu). */
|
|
export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> {
|
|
return request<{ enqueued: boolean }>(
|
|
`/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`,
|
|
{ method: 'POST' }
|
|
);
|
|
}
|
|
|
|
// ---- AI content analysis: coverage & inspection ----------------------------
|
|
|
|
export type MangaCoverage = {
|
|
manga_id: string;
|
|
title: string;
|
|
total_pages: number;
|
|
analyzed_pages: number;
|
|
};
|
|
|
|
export type MangaCoveragePage = {
|
|
items: MangaCoverage[];
|
|
page: Page;
|
|
};
|
|
|
|
export type ChapterCoverage = {
|
|
chapter_id: string;
|
|
number: number;
|
|
title: string | null;
|
|
total_pages: number;
|
|
analyzed_pages: number;
|
|
};
|
|
|
|
/** Per-page status in a chapter grid. */
|
|
export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none';
|
|
|
|
export type PageStatusItem = {
|
|
page_id: string;
|
|
page_number: number;
|
|
status: PageAnalysisStatus;
|
|
};
|
|
|
|
export type OcrLine = { kind: string; text: string };
|
|
|
|
export type PageAnalysisDetail = {
|
|
page_id: string;
|
|
page_number: number;
|
|
chapter_id: string;
|
|
manga_id: string;
|
|
/** `done` | `failed` | `none` (no analysis row yet). */
|
|
status: 'done' | 'failed' | 'none';
|
|
is_nsfw: boolean;
|
|
scene_description: string | null;
|
|
model: string | null;
|
|
error: string | null;
|
|
analyzed_at: string | null;
|
|
ocr: OcrLine[];
|
|
tags: string[];
|
|
content_warnings: ContentWarning[];
|
|
};
|
|
|
|
/** Paginated per-manga analysis coverage (admin overview). */
|
|
export async function getAnalysisMangaCoverage(
|
|
opts: { search?: string; limit?: number; offset?: number } = {}
|
|
): Promise<MangaCoveragePage> {
|
|
const params = new URLSearchParams();
|
|
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<MangaCoveragePage>(
|
|
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
|
|
);
|
|
}
|
|
|
|
export async function getAnalysisChapterCoverage(
|
|
mangaId: string
|
|
): Promise<ChapterCoverage[]> {
|
|
const r = await request<{ items: ChapterCoverage[] }>(
|
|
`/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
|
|
);
|
|
return r.items;
|
|
}
|
|
|
|
export async function getAnalysisChapterPages(
|
|
chapterId: string
|
|
): Promise<PageStatusItem[]> {
|
|
const r = await request<{ items: PageStatusItem[] }>(
|
|
`/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
|
|
);
|
|
return r.items;
|
|
}
|
|
|
|
export async function getAnalysisPageDetail(
|
|
pageId: string
|
|
): Promise<PageAnalysisDetail> {
|
|
return request<PageAnalysisDetail>(
|
|
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
|
|
);
|
|
}
|
|
|
|
/** 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 =
|
|
| {
|
|
kind: 'enqueued';
|
|
count: number;
|
|
manga_id: string | null;
|
|
chapter_id: string | null;
|
|
}
|
|
| {
|
|
kind: 'started' | 'completed' | 'failed';
|
|
page_id: string;
|
|
manga_id: string;
|
|
manga_title: string;
|
|
chapter_id: string;
|
|
chapter_number: number;
|
|
page_number: number;
|
|
};
|
|
|
|
/** URL of the live analysis SSE stream. Open with `new EventSource(...)`
|
|
* while the admin Analysis page is mounted and close it on navigate-away.
|
|
* Each message is a named `analysis` event whose `data` is an
|
|
* {@link AnalysisEvent}; a `lagged` event signals dropped frames. */
|
|
export function analysisStatusStreamUrl(): string {
|
|
return apiUrl('/v1/admin/analysis/status/stream');
|
|
}
|
|
|
|
// ---- runtime settings (crawler + analysis) ---------------------------------
|
|
|
|
/** Operationally-safe, admin-editable crawler settings. Host/infra and
|
|
* session/secret fields are managed via environment and surfaced read-only
|
|
* in {@link CrawlerEnvOnly}. */
|
|
export type CrawlerSettings = {
|
|
daemon_enabled: boolean;
|
|
daily_at: string; // "HH:MM"
|
|
tz: string; // IANA
|
|
idle_timeout_secs: number;
|
|
chapter_workers: number;
|
|
retention_days: number;
|
|
start_url: string | null;
|
|
rate_ms: number;
|
|
cdn_host: string | null;
|
|
cdn_rate_ms: number;
|
|
cookie_domain: string | null;
|
|
user_agent: string | null;
|
|
download_allowlist: string[];
|
|
allow_any_host: boolean;
|
|
max_image_bytes: number;
|
|
manga_limit: number;
|
|
job_timeout_secs: number;
|
|
metadata_max_consecutive_failures: number;
|
|
browser_restart_threshold: number;
|
|
};
|
|
|
|
/** Read-only view of crawler fields managed via environment. */
|
|
export type CrawlerEnvOnly = {
|
|
browser_mode: string;
|
|
browser_args: string[];
|
|
proxy: string | null;
|
|
tor_control_url: string | null;
|
|
tor_credentials_configured: boolean;
|
|
session_configured: boolean;
|
|
};
|
|
|
|
export type CrawlerSettingsResponse = {
|
|
editable: CrawlerSettings;
|
|
env_only: CrawlerEnvOnly;
|
|
};
|
|
|
|
/** Admin-editable analysis settings. Prompts are `null` to mean "use the
|
|
* compiled default" (see {@link PromptDefaults}). The vision API key is
|
|
* env-only and never returned. */
|
|
export type AnalysisSettings = {
|
|
enabled: boolean;
|
|
workers: number;
|
|
endpoint: string;
|
|
model: string;
|
|
request_timeout_secs: number;
|
|
job_timeout_secs: number;
|
|
max_tokens: number;
|
|
max_pixels: number;
|
|
min_slice_height: number;
|
|
slice_overlap: number;
|
|
tall_aspect_threshold: number;
|
|
max_slices: number;
|
|
max_image_bytes: number;
|
|
response_format: 'json_schema' | 'json_object' | 'none';
|
|
frequency_penalty: number;
|
|
temperature: number;
|
|
system_prompt: string | null;
|
|
ocr_prompt: string | null;
|
|
grounding_prompt: string | null;
|
|
};
|
|
|
|
export type PromptDefaults = {
|
|
system_prompt: string;
|
|
ocr_prompt: string;
|
|
grounding_prompt: string;
|
|
};
|
|
|
|
export type AnalysisSettingsResponse = {
|
|
editable: AnalysisSettings;
|
|
env_only: { api_key_configured: boolean };
|
|
prompt_defaults: PromptDefaults;
|
|
};
|
|
|
|
export async function getCrawlerSettings(): Promise<CrawlerSettingsResponse> {
|
|
return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler');
|
|
}
|
|
|
|
export async function updateCrawlerSettings(
|
|
settings: CrawlerSettings
|
|
): Promise<CrawlerSettingsResponse> {
|
|
return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(settings)
|
|
});
|
|
}
|
|
|
|
export async function getAnalysisSettings(): Promise<AnalysisSettingsResponse> {
|
|
return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis');
|
|
}
|
|
|
|
export async function updateAnalysisSettings(
|
|
settings: AnalysisSettings
|
|
): Promise<AnalysisSettingsResponse> {
|
|
return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(settings)
|
|
});
|
|
}
|
|
|
|
// ---- admin audit log -------------------------------------------------------
|
|
|
|
export type AuditEntry = {
|
|
id: string;
|
|
actor_user_id: string | null;
|
|
/** Actor's current username; null when the account was deleted. */
|
|
actor_username: string | null;
|
|
action: string;
|
|
target_kind: string;
|
|
target_id: string | null;
|
|
payload: unknown;
|
|
at: string;
|
|
};
|
|
|
|
export type AuditPage = { items: AuditEntry[]; page: Page };
|
|
|
|
export type ListAuditOptions = {
|
|
action?: string;
|
|
targetKind?: string;
|
|
actorUserId?: string;
|
|
days?: number;
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
export async function listAuditLog(
|
|
opts: ListAuditOptions = {},
|
|
init?: RequestInit
|
|
): Promise<AuditPage> {
|
|
const params = new URLSearchParams();
|
|
if (opts.action) params.set('action', opts.action);
|
|
if (opts.targetKind) params.set('target_kind', opts.targetKind);
|
|
if (opts.actorUserId) params.set('actor_user_id', opts.actorUserId);
|
|
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<AuditPage>(`/v1/admin/audit${qs ? `?${qs}` : ''}`, init);
|
|
}
|
|
|
|
// ---- operational health checks ---------------------------------------------
|
|
|
|
export type CheckStatus = 'ok' | 'warn' | 'critical';
|
|
|
|
export type HealthCheck = {
|
|
id: string;
|
|
label: string;
|
|
status: CheckStatus;
|
|
value: string;
|
|
threshold: string | null;
|
|
hint: string | null;
|
|
/** Admin route that remediates this check, when one applies. */
|
|
action_href: string | null;
|
|
};
|
|
|
|
export type HealthThresholds = {
|
|
disk_pct: number;
|
|
mem_pct: number;
|
|
dead_jobs_max: number;
|
|
crawl_fail_pct: number;
|
|
analysis_fail_pct: number;
|
|
cron_freshness_hours: number;
|
|
missing_covers_max: number;
|
|
};
|
|
|
|
export type HealthReport = {
|
|
status: CheckStatus;
|
|
checks: HealthCheck[];
|
|
thresholds: HealthThresholds;
|
|
};
|
|
|
|
export async function getHealth(init?: RequestInit): Promise<HealthReport> {
|
|
return request<HealthReport>('/v1/admin/health', init);
|
|
}
|
|
|
|
export async function updateHealthThresholds(t: HealthThresholds): Promise<HealthReport> {
|
|
return request<HealthReport>('/v1/admin/health/thresholds', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(t)
|
|
});
|
|
}
|