feat(frontend): /admin dashboard with users/mangas/system views (0.41.0)

Adds the SvelteKit /admin route tree backed by the admin endpoints
landed in PR 1-4. Pages: Overview (alerts + summary cards), Users
(list / promote-demote / delete), Mangas (list with sync state +
expandable per-chapter state), System (live disk/mem/cpu bars,
refreshing every 5s).

Security model: the backend's RequireAdmin extractor is the actual
boundary. /admin/+layout.ts calls getSystemStats() at load and
translates the response — 401 → redirect to /login, 403 → throw
SvelteKit error(403) which renders the framework error page. The
header's "Admin" link is hidden unless `session.user?.is_admin`,
but that's UX only.

Carries `is_admin: boolean` through to the frontend User TS type so
the header check works and so admin tables can show role per row.

Vitest covers lib/api/admin.ts (10 tests: list/delete/PATCH for
users, sync-state filter for mangas, nested chapter route, system
disk-nullable case). Playwright is intentionally deferred until the
routes stabilise — admin UI is operator-only and changes shape often
in v0.
This commit is contained in:
MechaCat02
2026-05-30 21:49:39 +02:00
parent cc4ec76d17
commit b434c9b68d
13 changed files with 1206 additions and 3 deletions

View File

@@ -10,6 +10,7 @@
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import LogOut from '@lucide/svelte/icons/log-out';
import Shield from '@lucide/svelte/icons/shield';
import '$lib/styles/tokens.css';
let { children } = $props();
@@ -86,6 +87,12 @@
<FolderOpen size={18} aria-hidden="true" />
<span>Collections</span>
</a>
{#if session.user?.is_admin}
<a class="nav-link" href="/admin" data-testid="nav-admin">
<Shield size={18} aria-hidden="true" />
<span>Admin</span>
</a>
{/if}
</nav>
<div class="session" data-testid="session-area">
{#if !session.loaded}

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { page } from '$app/stores';
let { children } = $props();
const tabs = [
{ href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/mangas', label: 'Mangas' },
{ href: '/admin/system', label: 'System' }
];
</script>
<div class="admin-frame">
<aside aria-label="admin">
<h2>Admin</h2>
<nav>
{#each tabs as t (t.href)}
<a
href={t.href}
class:active={$page.url.pathname === t.href}
data-testid={`admin-nav-${t.label.toLowerCase()}`}
>
{t.label}
</a>
{/each}
</nav>
</aside>
<section>
{@render children()}
</section>
</div>
<style>
.admin-frame {
display: grid;
grid-template-columns: 12rem 1fr;
gap: var(--space-4);
align-items: start;
}
@media (max-width: 600px) {
.admin-frame {
grid-template-columns: 1fr;
}
}
aside {
position: sticky;
top: calc(var(--app-header-h) + var(--space-4));
}
aside h2 {
margin: 0 0 var(--space-3) 0;
font-size: var(--font-base);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
nav a {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--text);
font-size: var(--font-sm);
}
nav a:hover {
background: var(--surface-elevated);
text-decoration: none;
}
nav a.active {
background: var(--surface-elevated);
font-weight: var(--weight-semibold);
}
</style>

View File

@@ -0,0 +1,31 @@
// /admin gate. The backend's RequireAdmin extractor is the actual
// security boundary — this load function just calls a tiny admin
// endpoint and translates the response into either a redirect (no
// session) or SvelteKit's framework error page (403 forbidden).
// The session.user?.is_admin check elsewhere is UX only.
//
// `ssr=false` because the session store is browser-only (see
// $lib/session.svelte.ts) — server-side load can't read the cookie
// anyway in this app's deployment shape.
import { error, redirect } from '@sveltejs/kit';
import { ApiError } from '$lib/api/client';
import { getSystemStats } from '$lib/api/admin';
import type { LayoutLoad } from './$types';
export const ssr = false;
export const load: LayoutLoad = async () => {
try {
const stats = await getSystemStats();
return { stats };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
throw redirect(302, '/login');
}
if (e instanceof ApiError && e.status === 403) {
throw error(403, 'admin access required');
}
throw e;
}
};

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import type { LayoutData } from './$types';
let { data }: { data: LayoutData } = $props();
const stats = $derived(data.stats);
function fmtPercent(n: number): string {
return `${n.toFixed(1)}%`;
}
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(1)} ${units[i]}`;
}
</script>
<h1>Overview</h1>
{#if stats.alerts.length > 0}
<section class="alerts" data-testid="admin-alerts">
{#each stats.alerts as a (a.message)}
<div class="alert" data-level={a.level}>{a.message}</div>
{/each}
</section>
{/if}
<section class="cards">
{#if stats.disk}
<article class="card">
<h3>Disk</h3>
<p class="metric">{fmtPercent(stats.disk.percent_used)}</p>
<p class="sub">{fmtBytes(stats.disk.used_bytes)} of {fmtBytes(stats.disk.total_bytes)} used</p>
</article>
{:else}
<article class="card muted">
<h3>Disk</h3>
<p>n/a (non-local storage)</p>
</article>
{/if}
<article class="card">
<h3>Memory</h3>
<p class="metric">{fmtPercent(stats.memory.percent_used)}</p>
<p class="sub">{fmtBytes(stats.memory.used_bytes)} of {fmtBytes(stats.memory.total_bytes)} used</p>
</article>
<article class="card">
<h3>CPU</h3>
<p class="metric">{fmtPercent(stats.cpu.percent_used)}</p>
<p class="sub">global load</p>
</article>
</section>
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
.alerts {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.alert {
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-elevated);
border-left: 4px solid var(--warning, #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.muted {
opacity: 0.6;
}
.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);
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listAdminMangas,
listAdminChapters,
type AdminMangasPage,
type AdminChapterRow,
type MangaSyncState
} from '$lib/api/admin';
import { ApiError } from '$lib/api/client';
let mangasPage: AdminMangasPage | null = $state(null);
let search = $state('');
let syncFilter: MangaSyncState | '' = $state('');
let error: string | null = $state(null);
let expandedId: string | null = $state(null);
let chaptersByManga: Record<string, AdminChapterRow[] | 'loading'> = $state({});
async function load() {
error = null;
try {
mangasPage = await listAdminMangas({
search: search.trim() || undefined,
syncState: syncFilter || undefined,
limit: 100
});
} catch (e) {
error = e instanceof ApiError ? e.message : 'load failed';
}
}
onMount(load);
async function toggleChapters(id: string) {
if (expandedId === id) {
expandedId = null;
return;
}
expandedId = id;
if (!chaptersByManga[id]) {
chaptersByManga[id] = 'loading';
try {
chaptersByManga[id] = await listAdminChapters(id);
} catch {
delete chaptersByManga[id];
error = 'failed to load chapters';
}
}
}
function badgeClass(state: string): string {
return `badge badge-${state}`;
}
</script>
<h1>Mangas</h1>
<form
onsubmit={(e) => {
e.preventDefault();
load();
}}
>
<input
type="search"
placeholder="search by title"
bind:value={search}
data-testid="admin-mangas-search"
/>
<select bind:value={syncFilter} aria-label="sync state">
<option value="">all states</option>
<option value="in_progress">in progress</option>
<option value="dropped">dropped</option>
<option value="synced">synced</option>
</select>
<button type="submit">Search</button>
</form>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if mangasPage}
<p class="total">{mangasPage.page.total ?? mangasPage.items.length} mangas</p>
<table>
<thead>
<tr>
<th>Title</th>
<th>Sync</th>
<th>Chapters</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{#each mangasPage.items as m (m.id)}
<tr>
<td>
<button class="link" onclick={() => toggleChapters(m.id)}>
{expandedId === m.id ? '▼' : '▶'} {m.title}
</button>
</td>
<td><span class={badgeClass(m.sync_state)}>{m.sync_state}</span></td>
<td>{m.chapter_count}</td>
<td>
{m.latest_seen_at
? new Date(m.latest_seen_at).toLocaleDateString()
: '—'}
</td>
</tr>
{#if expandedId === m.id}
<tr class="chapter-row">
<td colspan="4">
{#if chaptersByManga[m.id] === 'loading'}
<p>Loading chapters…</p>
{:else if chaptersByManga[m.id]}
{@const list = chaptersByManga[m.id] as AdminChapterRow[]}
{#if list.length === 0}
<p class="muted">No chapters.</p>
{:else}
<table class="inner">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Pages</th>
<th>Sync</th>
</tr>
</thead>
<tbody>
{#each list as c (c.id)}
<tr>
<td>{c.number}</td>
<td>{c.title ?? '—'}</td>
<td>{c.page_count}</td>
<td>
<span class={badgeClass(c.sync_state)}>
{c.sync_state}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{:else}
<p>Loading…</p>
{/if}
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
form {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
button {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button.link {
background: none;
border: none;
padding: 0;
color: var(--text);
cursor: pointer;
font-weight: inherit;
}
button.link:hover {
color: var(--primary);
}
.total {
color: var(--text-muted);
font-size: var(--font-sm);
margin: 0 0 var(--space-2) 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--space-2);
text-align: left;
border-bottom: 1px solid var(--border);
}
.chapter-row td {
background: var(--surface-elevated);
}
table.inner {
margin: var(--space-2) 0;
}
.badge {
display: inline-block;
padding: 0 var(--space-2);
border-radius: var(--radius-sm, 4px);
font-size: var(--font-xs, 0.75rem);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid var(--border);
background: var(--surface);
}
.badge-in_progress,
.badge-downloading {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.badge-dropped {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.badge-failed {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.badge-not_downloaded {
background: var(--surface-elevated);
color: var(--text-muted);
}
.badge-synced {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.muted {
color: var(--text-muted);
}
.error {
color: var(--danger, #dc2626);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger, #dc2626);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
</style>

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSystemStats, type SystemStats } from '$lib/api/admin';
let stats: SystemStats | null = $state(null);
let error: string | null = $state(null);
let timer: ReturnType<typeof setInterval> | null = null;
async function refresh() {
try {
stats = await getSystemStats();
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'refresh failed';
}
}
onMount(() => {
refresh();
timer = setInterval(refresh, 5000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
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]}`;
}
</script>
<h1>System</h1>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if stats}
{#if stats.alerts.length > 0}
<section class="alerts">
{#each stats.alerts as a (a.message)}
<div class="alert">{a.message}</div>
{/each}
</section>
{/if}
<section class="grid">
<article>
<h2>Disk (storage_dir)</h2>
{#if stats.disk}
{@render Bar({ percent: stats.disk.percent_used })}
<dl>
<dt>Total</dt>
<dd>{fmtBytes(stats.disk.total_bytes)}</dd>
<dt>Used</dt>
<dd>{fmtBytes(stats.disk.used_bytes)}</dd>
<dt>Free</dt>
<dd>{fmtBytes(stats.disk.free_bytes)}</dd>
</dl>
{:else}
<p class="muted">n/a — non-local storage backend</p>
{/if}
</article>
<article>
<h2>Memory</h2>
{@render Bar({ percent: stats.memory.percent_used })}
<dl>
<dt>Total</dt>
<dd>{fmtBytes(stats.memory.total_bytes)}</dd>
<dt>Used</dt>
<dd>{fmtBytes(stats.memory.used_bytes)}</dd>
</dl>
</article>
<article>
<h2>CPU</h2>
{@render Bar({ percent: stats.cpu.percent_used })}
</article>
</section>
<p class="hint">refreshing every 5 s</p>
{:else}
<p>Loading…</p>
{/if}
{#snippet Bar({ percent }: { percent: number })}
<div
class="bar"
role="progressbar"
aria-valuenow={percent}
aria-valuemin="0"
aria-valuemax="100"
aria-label="{percent.toFixed(1)}% used"
>
<div
class="fill"
class:high={percent >= 90}
class:mid={percent >= 70 && percent < 90}
style:width="{Math.min(100, Math.max(0, percent))}%"
></div>
<span class="label">{percent.toFixed(1)}%</span>
</div>
{/snippet}
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-3);
}
article {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
article h2 {
margin: 0 0 var(--space-3) 0;
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.bar {
position: relative;
background: var(--surface-elevated);
border-radius: var(--radius-sm, 4px);
height: 1.5rem;
margin-bottom: var(--space-2);
overflow: hidden;
}
.fill {
height: 100%;
background: #22c55e;
transition: width 0.3s ease, background 0.3s ease;
}
.fill.mid {
background: #f59e0b;
}
.fill.high {
background: #dc2626;
}
.label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
color: var(--text);
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-3);
margin: 0;
font-size: var(--font-sm);
}
dt {
color: var(--text-muted);
}
dd {
margin: 0;
font-family: var(--font-mono, monospace);
}
.alerts {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.alert {
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-elevated);
border-left: 4px solid #f59e0b;
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
margin-top: var(--space-3);
}
.muted {
color: var(--text-muted);
}
.error {
color: var(--danger, #dc2626);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger, #dc2626);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
</style>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listAdminUsers,
deleteAdminUser,
setUserAdmin,
type AdminUsersPage
} from '$lib/api/admin';
import { ApiError } from '$lib/api/client';
import { session } from '$lib/session.svelte';
let page: AdminUsersPage | null = $state(null);
let search = $state('');
let error: string | null = $state(null);
let busyId: string | null = $state(null);
async function load() {
error = null;
try {
page = await listAdminUsers({
search: search.trim() || undefined,
limit: 100
});
} catch (e) {
error = e instanceof ApiError ? e.message : 'load failed';
}
}
onMount(load);
async function onDelete(id: string) {
if (!confirm('Delete this user? This cannot be undone.')) return;
busyId = id;
try {
await deleteAdminUser(id);
await load();
} catch (e) {
error = e instanceof ApiError ? e.message : 'delete failed';
} finally {
busyId = null;
}
}
async function onToggleAdmin(id: string, next: boolean) {
busyId = id;
try {
await setUserAdmin(id, next);
await load();
} catch (e) {
error = e instanceof ApiError ? e.message : 'update failed';
} finally {
busyId = null;
}
}
</script>
<h1>Users</h1>
<form
onsubmit={(e) => {
e.preventDefault();
load();
}}
>
<input
type="search"
placeholder="search by username"
bind:value={search}
data-testid="admin-users-search"
/>
<button type="submit">Search</button>
</form>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if page}
<p class="total">{page.page.total ?? page.items.length} users</p>
<table>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Created</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
{#each page.items as u (u.id)}
{@const isSelf = session.user?.id === u.id}
<tr>
<td>{u.username}{#if isSelf}<span class="self"> (you)</span>{/if}</td>
<td>
<input
type="checkbox"
checked={u.is_admin}
disabled={busyId === u.id || isSelf}
onchange={(e) => onToggleAdmin(u.id, e.currentTarget.checked)}
aria-label="admin"
/>
</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td class="actions">
<button
type="button"
class="danger"
disabled={busyId === u.id || isSelf}
onclick={() => onDelete(u.id)}
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>Loading…</p>
{/if}
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
form {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
button {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button.danger {
color: var(--danger, #dc2626);
border-color: var(--danger, #dc2626);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.total {
color: var(--text-muted);
font-size: var(--font-sm);
margin: 0 0 var(--space-2) 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--space-2);
text-align: left;
border-bottom: 1px solid var(--border);
}
.actions {
text-align: right;
}
.self {
color: var(--text-muted);
font-size: var(--font-sm);
}
.error {
color: var(--danger, #dc2626);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger, #dc2626);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
</style>