Files
Mangalord/frontend/src/lib/api/admin.ts
MechaCat02 790549636f
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:

- Admin System tab: total/covers/chapters totals, ratio of disk, average
  image sizes, and top-5 largest mangas/chapters leaderboards, behind a
  new GET /api/v1/admin/storage endpoint (separate from /admin/system so
  the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
  with an em-dash for uncrawled or not-yet-measured chapters.

Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.

Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:18:41 +02:00

721 lines
22 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 CpuStats = {
percent_used: number;
};
export type Alert = {
level: 'warning';
message: string;
};
export type SystemStats = {
disk: DiskStats | null;
memory: MemoryStats;
cpu: CpuStats;
alerts: Alert[];
};
export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>('/v1/admin/system');
}
// ---- 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 };
/** 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/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;
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
);
}
/** 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 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;
chapter_id: string;
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)
});
}