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>
This commit is contained in:
@@ -180,6 +180,61 @@ 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 = {
|
||||
|
||||
Reference in New Issue
Block a user