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:
@@ -13,6 +13,7 @@
|
||||
} from '$lib/api/mangas';
|
||||
import { resyncManga } from '$lib/api/admin';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { formatBytes } from '$lib/upload-validation';
|
||||
import { listTags, type Tag } from '$lib/api/tags';
|
||||
import type { ContentWarning } from '$lib/api/page_tags';
|
||||
import { session } from '$lib/session.svelte';
|
||||
@@ -39,6 +40,12 @@
|
||||
const manga = $derived<MangaDetail>(mangaOverride ?? data.manga);
|
||||
const chapters = $derived(data.chapters);
|
||||
const readProgress = $derived(data.readProgress);
|
||||
/** Total stored bytes of this manga's chapter content (cover excluded).
|
||||
* Comes from the backend, which sums over ALL chapters (the loaded
|
||||
* chapter list is paginated, so summing it client-side would
|
||||
* undercount mangas with many chapters). `null` → unknown (a page is
|
||||
* un-backfilled) → show an em-dash; `0` → no content → hide. */
|
||||
const contentBytes = $derived(manga.chapter_storage_bytes);
|
||||
/** Chapter row from the local chapters list when present (so we
|
||||
* can also surface the chapter title). Falls back below to the
|
||||
* server-supplied `chapter_number` when the chapter sits past
|
||||
@@ -589,7 +596,14 @@
|
||||
{/if}
|
||||
|
||||
<section aria-label="chapters">
|
||||
<h2>Chapters</h2>
|
||||
<div class="chapters-head">
|
||||
<h2>Chapters</h2>
|
||||
{#if contentBytes === null || contentBytes > 0}
|
||||
<span class="content-size" data-testid="manga-content-size">
|
||||
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if continueChapterId != null && continueChapterNumber != null}
|
||||
<a
|
||||
class="continue"
|
||||
@@ -615,6 +629,11 @@
|
||||
{chapterLabel(c)}
|
||||
</a>
|
||||
<span class="pages">({c.page_count} pages)</span>
|
||||
<span class="size" data-testid="chapter-size">
|
||||
{c.page_count === 0 || c.size_bytes === null
|
||||
? '—'
|
||||
: formatBytes(c.size_bytes)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -996,6 +1015,26 @@
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.chapters-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.content-size {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.size {
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* Mobile hero + chrome — hidden above 640px so the existing
|
||||
desktop layout stays untouched. */
|
||||
.mobile-hero {
|
||||
|
||||
Reference in New Issue
Block a user