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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.40.0"
|
||||
version = "0.41.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.40.0"
|
||||
version = "0.41.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
188
frontend/src/lib/api/admin.test.ts
Normal file
188
frontend/src/lib/api/admin.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
listAdminUsers,
|
||||
deleteAdminUser,
|
||||
setUserAdmin,
|
||||
listAdminMangas,
|
||||
listAdminChapters,
|
||||
getSystemStats
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function envelope(status: number, code: string, message: string): Response {
|
||||
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const userFixture = {
|
||||
id: 'u-1',
|
||||
username: 'alice',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
|
||||
const mangaFixture = {
|
||||
id: 'm-1',
|
||||
title: 'Test',
|
||||
status: 'ongoing',
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
sync_state: 'synced' as const,
|
||||
chapter_count: 3,
|
||||
latest_seen_at: '2026-01-02T00:00:00Z'
|
||||
};
|
||||
|
||||
const systemFixture = {
|
||||
disk: {
|
||||
total_bytes: 1_000_000,
|
||||
used_bytes: 500_000,
|
||||
free_bytes: 500_000,
|
||||
percent_used: 50.0
|
||||
},
|
||||
memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 },
|
||||
cpu: { percent_used: 12.3 },
|
||||
alerts: []
|
||||
};
|
||||
|
||||
describe('admin api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ---- users ----
|
||||
|
||||
it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } })
|
||||
);
|
||||
const page = await listAdminUsers({ limit: 50 });
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.items[0]).toEqual(userFixture);
|
||||
expect(page.page.total).toBe(1);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/);
|
||||
});
|
||||
|
||||
it('listAdminUsers forwards search + offset query params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 10, total: 0 } })
|
||||
);
|
||||
await listAdminUsers({ search: 'al', offset: 10 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('search=al');
|
||||
expect(url).toContain('offset=10');
|
||||
});
|
||||
|
||||
it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden'));
|
||||
await expect(listAdminUsers()).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await expect(deleteAdminUser('u-1')).resolves.toBeUndefined();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/users\/u-1$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
envelope(409, 'conflict', 'cannot delete yourself; ask another admin')
|
||||
);
|
||||
await expect(deleteAdminUser('u-1')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: 'conflict'
|
||||
});
|
||||
});
|
||||
|
||||
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
||||
const updated = { ...userFixture, is_admin: true };
|
||||
fetchSpy.mockResolvedValueOnce(ok(updated));
|
||||
const got = await setUserAdmin('u-1', true);
|
||||
expect(got).toEqual(updated);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ is_admin: true });
|
||||
});
|
||||
|
||||
// ---- mangas + chapters ----
|
||||
|
||||
it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } })
|
||||
);
|
||||
const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 });
|
||||
expect(page.items[0].sync_state).toBe('synced');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('sync_state=in_progress');
|
||||
expect(url).toContain('limit=100');
|
||||
});
|
||||
|
||||
it('listAdminChapters GETs the nested chapter route', async () => {
|
||||
const chapter = {
|
||||
id: 'c-1',
|
||||
manga_id: 'm-1',
|
||||
number: 1,
|
||||
title: null,
|
||||
page_count: 12,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
sync_state: 'synced' as const,
|
||||
latest_seen_at: null
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(ok([chapter]));
|
||||
const rows = await listAdminChapters('m-1');
|
||||
expect(rows).toEqual([chapter]);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
|
||||
});
|
||||
|
||||
// ---- system ----
|
||||
|
||||
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(systemFixture));
|
||||
const s = await getSystemStats();
|
||||
expect(s.disk?.percent_used).toBe(50);
|
||||
expect(s.memory.percent_used).toBe(50);
|
||||
expect(s.cpu.percent_used).toBe(12.3);
|
||||
expect(s.alerts).toEqual([]);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/system$/);
|
||||
});
|
||||
|
||||
it('getSystemStats keeps disk null when backend reports a non-local store', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null }));
|
||||
const s = await getSystemStats();
|
||||
expect(s.disk).toBeNull();
|
||||
});
|
||||
});
|
||||
144
frontend/src/lib/api/admin.ts
Normal file
144
frontend/src/lib/api/admin.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// 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, type Page } from './client';
|
||||
import type { User } from './auth';
|
||||
|
||||
// ---- 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 })
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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 async function listAdminChapters(mangaId: string): Promise<AdminChapterRow[]> {
|
||||
return request<AdminChapterRow[]>(
|
||||
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters`
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type Credentials = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
75
frontend/src/routes/admin/+layout.svelte
Normal file
75
frontend/src/routes/admin/+layout.svelte
Normal 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>
|
||||
31
frontend/src/routes/admin/+layout.ts
Normal file
31
frontend/src/routes/admin/+layout.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
104
frontend/src/routes/admin/+page.svelte
Normal file
104
frontend/src/routes/admin/+page.svelte
Normal 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>
|
||||
266
frontend/src/routes/admin/mangas/+page.svelte
Normal file
266
frontend/src/routes/admin/mangas/+page.svelte
Normal 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>
|
||||
203
frontend/src/routes/admin/system/+page.svelte
Normal file
203
frontend/src/routes/admin/system/+page.svelte
Normal 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>
|
||||
184
frontend/src/routes/admin/users/+page.svelte
Normal file
184
frontend/src/routes/admin/users/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user