feat(storage): admin storage-usage stats + per-manga/chapter sizes
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

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:
MechaCat02
2026-06-15 20:14:49 +02:00
parent 4b6c19979a
commit 790549636f
35 changed files with 1804 additions and 40 deletions

View File

@@ -15,6 +15,8 @@ import {
listAdminMangas,
listAdminChapters,
getSystemStats,
getStorageStats,
backfillStorage,
resyncManga,
resyncChapter,
getCrawlerStatus,
@@ -587,4 +589,66 @@ describe('admin crawler api client', () => {
/\/v1\/admin\/analysis\/status\/stream$/
);
});
// ---- storage usage ----
it('getStorageStats GETs /v1/admin/storage and parses the envelope', async () => {
const fixture = {
total_bytes: 700,
covers_bytes: 100,
chapters_bytes: 600,
disk_total_bytes: 1_000_000,
ratio_of_disk: 0.0007,
avg_image_bytes: 233.3,
avg_cover_bytes: 100,
avg_chapter_page_bytes: 300,
top_mangas: [{ id: 'm-1', title: 'Big', total_bytes: 650 }],
top_chapters: [
{ id: 'c-1', manga_id: 'm-1', number: 1, title: null, total_bytes: 600 }
],
unmeasured_pages: 4,
unmeasured_covers: 1
};
fetchSpy.mockResolvedValueOnce(ok(fixture));
const s = await getStorageStats();
expect(s.total_bytes).toBe(700);
expect(s.top_mangas[0].title).toBe('Big');
expect(s.top_chapters[0].total_bytes).toBe(600);
expect(s.unmeasured_pages).toBe(4);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/storage$/);
});
it('getStorageStats keeps disk total + ratio null for a non-local store', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
total_bytes: 0,
covers_bytes: 0,
chapters_bytes: 0,
disk_total_bytes: null,
ratio_of_disk: null,
avg_image_bytes: null,
avg_cover_bytes: null,
avg_chapter_page_bytes: null,
top_mangas: [],
top_chapters: [],
unmeasured_pages: 0,
unmeasured_covers: 0
})
);
const s = await getStorageStats();
expect(s.disk_total_bytes).toBeNull();
expect(s.ratio_of_disk).toBeNull();
});
it('backfillStorage POSTs /v1/admin/storage/backfill and returns counts', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false })
);
const r = await backfillStorage();
expect(r).toEqual({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/storage\/backfill$/);
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
});
});

View File

@@ -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 = {

View File

@@ -7,6 +7,10 @@ export type Chapter = {
title: string | null;
page_count: number;
created_at: string;
/** Total bytes of this chapter's stored pages. `0` for an uncrawled
* chapter; `null` when the size is unknown (a page predates the size
* backfill). Render both `null` and uncrawled as an em-dash. */
size_bytes: number | null;
};
export type ChaptersPage = {

View File

@@ -20,6 +20,10 @@ export type MangaDetail = Manga & {
tags: TagRef[];
/** Deduped union of content warnings across the manga's pages. */
content_warnings: ContentWarning[];
/** Total bytes of all this manga's stored chapter pages (cover
* excluded). `null` when any page is unmeasured (show an em-dash);
* `0` when there are no pages. */
chapter_storage_bytes: number | null;
};
export type ListOptions = {

View File

@@ -49,4 +49,9 @@ describe('formatBytes', () => {
expect(formatBytes(2048)).toBe('2 KiB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB');
});
it('scales up to GiB and TiB for large storage totals', () => {
expect(formatBytes(6 * 1024 ** 3)).toBe('6.0 GiB');
expect(formatBytes(Math.round(1.5 * 1024 ** 4))).toBe('1.5 TiB');
});
});

View File

@@ -16,7 +16,11 @@ export const ALLOWED_IMAGE_TYPES = [
export type ImageType = (typeof ALLOWED_IMAGE_TYPES)[number];
/** Binary (1024-based) human byte size. Scales up to TiB so it can format
* whole-manga / whole-chapter storage totals, not just upload limits. */
export function formatBytes(n: number): string {
if (n >= 1024 ** 4) return `${(n / 1024 ** 4).toFixed(1)} TiB`;
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GiB`;
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`;
if (n >= 1024) return `${(n / 1024).toFixed(0)} KiB`;
return `${n} B`;

View File

@@ -1,10 +1,26 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSystemStats, type SystemStats } from '$lib/api/admin';
import {
getSystemStats,
getStorageStats,
backfillStorage,
type SystemStats,
type StorageStats
} from '$lib/api/admin';
import { formatBytes as fmtBytes } from '$lib/upload-validation';
let stats: SystemStats | null = $state(null);
let error: string | null = $state(null);
let timer: ReturnType<typeof setInterval> | null = null;
let storageTimer: ReturnType<typeof setInterval> | null = null;
// Storage usage is a cheap DB aggregate — refreshed alongside the
// system poll so the totals/leaderboards don't go stale while crawls
// and uploads land elsewhere.
let storage: StorageStats | null = $state(null);
let storageError: string | null = $state(null);
let backfilling = $state(false);
let backfillNote: string | null = $state(null);
async function refresh() {
try {
@@ -15,23 +31,57 @@
}
}
async function refreshStorage() {
try {
storage = await getStorageStats();
storageError = null;
} catch (e) {
storageError = e instanceof Error ? e.message : 'failed to load storage usage';
}
}
async function runBackfill() {
backfilling = true;
backfillNote = null;
try {
const r = await backfillStorage();
const extra = [
r.missing > 0 ? `${r.missing} missing` : '',
r.errored > 0 ? `${r.errored} errored` : ''
].filter(Boolean);
backfillNote =
`Backfilled ${r.pages} page(s), ${r.covers} cover(s)${
extra.length ? ` (${extra.join(', ')})` : ''
}.` + (r.more_remaining ? ' More remain — run again to finish.' : '');
await refreshStorage();
} catch (e) {
backfillNote = e instanceof Error ? e.message : 'backfill failed';
} finally {
backfilling = false;
}
}
onMount(() => {
refresh();
refreshStorage();
// System metrics (cheap) poll fast; storage stats run library-scaling
// aggregations, so they refresh on a slower cadence — fresh enough for
// an admin view without re-running the leaderboard queries every 5 s.
timer = setInterval(refresh, 5000);
storageTimer = setInterval(refreshStorage, 30000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
if (storageTimer) clearInterval(storageTimer);
});
function fmtBytes(n: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(2)} ${units[i]}`;
function fmtPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
// Distinct from the shared chapterLabel() (which renders just the
// title): the leaderboard needs the chapter number too.
function chapterRef(c: { number: number; title: string | null }): string {
return c.title ? `Ch. ${c.number}${c.title}` : `Ch. ${c.number}`;
}
</script>
@@ -90,6 +140,97 @@
<p>Loading…</p>
{/if}
<section class="storage" aria-label="storage usage">
<header class="storage-head">
<h2>Storage</h2>
<button
type="button"
onclick={runBackfill}
disabled={backfilling}
aria-busy={backfilling}
>
{backfilling ? 'Backfilling…' : 'Backfill sizes'}
</button>
</header>
{#if storageError}
<p class="error" role="alert">{storageError}</p>
{/if}
{#if backfillNote}
<p class="note" role="status">{backfillNote}</p>
{/if}
{#if storage && (storage.unmeasured_pages > 0 || storage.unmeasured_covers > 0)}
<p class="unmeasured" role="status">
{storage.unmeasured_pages + storage.unmeasured_covers} item(s) not yet measured —
figures below are partial and leaderboards omit them. Run “Backfill sizes” to complete.
</p>
{/if}
{#if storage}
<div class="cards">
<article class="card">
<h3>Total content</h3>
<p class="metric">{fmtBytes(storage.total_bytes)}</p>
{#if storage.ratio_of_disk !== null}
<p class="sub">{fmtPercent(storage.ratio_of_disk)} of disk</p>
{/if}
</article>
<article class="card">
<h3>Covers</h3>
<p class="metric">{fmtBytes(storage.covers_bytes)}</p>
{#if storage.avg_cover_bytes !== null}
<p class="sub">avg {fmtBytes(storage.avg_cover_bytes)}</p>
{/if}
</article>
<article class="card">
<h3>Chapters</h3>
<p class="metric">{fmtBytes(storage.chapters_bytes)}</p>
{#if storage.avg_chapter_page_bytes !== null}
<p class="sub">avg {fmtBytes(storage.avg_chapter_page_bytes)}/page</p>
{/if}
</article>
</div>
{#if storage.avg_image_bytes !== null}
<p class="hint">avg image (all): {fmtBytes(storage.avg_image_bytes)}</p>
{/if}
<div class="boards">
<article class="board">
<h3>Largest mangas</h3>
{#if storage.top_mangas.length === 0}
<p class="muted">No content yet.</p>
{:else}
<ol>
{#each storage.top_mangas as m (m.id)}
<li>
<a href="/manga/{m.id}">{m.title}</a>
<span class="bytes">{fmtBytes(m.total_bytes)}</span>
</li>
{/each}
</ol>
{/if}
</article>
<article class="board">
<h3>Largest chapters</h3>
{#if storage.top_chapters.length === 0}
<p class="muted">No content yet.</p>
{:else}
<ol>
{#each storage.top_chapters as c (c.id)}
<li>
<a href="/manga/{c.manga_id}/chapter/{c.id}">{chapterRef(c)}</a>
<span class="bytes">{fmtBytes(c.total_bytes)}</span>
</li>
{/each}
</ol>
{/if}
</article>
</div>
{:else if !storageError}
<p>Loading…</p>
{/if}
</section>
{#snippet Bar({ percent }: { percent: number })}
<div
class="bar"
@@ -200,4 +341,107 @@
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.storage {
margin-top: var(--space-5, 2rem);
}
.storage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.storage-head h2 {
margin: 0;
}
.storage-head button {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
cursor: pointer;
font-size: var(--font-sm);
}
.storage-head button:disabled {
opacity: 0.6;
cursor: default;
}
.note {
color: var(--text-muted);
font-size: var(--font-sm);
margin: 0 0 var(--space-3) 0;
}
.unmeasured {
padding: var(--space-2) var(--space-3);
margin: 0 0 var(--space-3) 0;
font-size: var(--font-sm);
border-radius: var(--radius-md);
background: var(--surface-elevated);
border-left: 4px solid #f59e0b;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--space-3);
}
.card {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.card h3 {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric {
margin: 0;
font-size: var(--font-xl, 1.5rem);
font-weight: var(--weight-semibold);
}
.sub {
margin: var(--space-1) 0 0 0;
font-size: var(--font-sm);
color: var(--text-muted);
}
.boards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
}
.board {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.board h3 {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.board ol {
margin: 0;
padding-left: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.board li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--font-sm);
}
.board .bytes {
font-family: var(--font-mono, monospace);
color: var(--text-muted);
white-space: nowrap;
}
</style>

View File

@@ -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 {