feat: implement admin dashboard
Add Admin Dashboard at /admin for server configuration, disk usage
monitoring, and export job status, plus a public export/status endpoint.
Backend — new /api/v1/admin/* endpoints (RequireAdmin auth):
- GET /admin/stats → user/upload/comment counts + disk usage
- GET /admin/config → all config key/value pairs
- PATCH /admin/config → update any subset of config keys; validates
key whitelist and numeric values
- GET /admin/export/jobs → export_job rows for the event
Backend — public (AuthUser) endpoint:
- GET /export/status → released flag + zip/html job status/progress
Frontend — /admin page:
- Stats grid: guest count, upload count, comment count
- Disk usage bar with GB/MB formatting; red ≥ 90%, amber ≥ 75%
- Config form: labelled numeric inputs for all eight config keys,
sends only changed values on save
- Export jobs list: type label, status badge, progress bar for running jobs,
error message if failed; manual refresh button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
266
frontend/src/routes/admin/+page.svelte
Normal file
266
frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken, getRole } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface StatsDto {
|
||||
user_count: number;
|
||||
upload_count: number;
|
||||
comment_count: number;
|
||||
disk_total_bytes: number;
|
||||
disk_used_bytes: number;
|
||||
disk_free_bytes: number;
|
||||
}
|
||||
|
||||
interface ExportJob {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
progress_pct: number;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
const CONFIG_LABELS: Record<string, string> = {
|
||||
max_image_size_mb: 'Max. Bildgröße (MB)',
|
||||
max_video_size_mb: 'Max. Videogröße (MB)',
|
||||
upload_rate_per_hour: 'Upload-Limit pro Stunde',
|
||||
feed_rate_per_min: 'Feed-Anfragen pro Minute',
|
||||
export_rate_per_day: 'Export-Downloads pro Tag',
|
||||
quota_tolerance: 'Speicherkontingent-Toleranz (0–1)',
|
||||
estimated_guest_count: 'Geschätzte Gästezahl',
|
||||
compression_concurrency: 'Kompressions-Worker'
|
||||
};
|
||||
|
||||
let stats = $state<StatsDto | null>(null);
|
||||
let config = $state<Record<string, string>>({});
|
||||
let configDraft = $state<Record<string, string>>({});
|
||||
let exportJobs = $state<ExportJob[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let toast = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const token = getToken();
|
||||
const role = getRole();
|
||||
if (!token || role !== 'admin') {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[stats, config, exportJobs] = await Promise.all([
|
||||
api.get<StatsDto>('/admin/stats'),
|
||||
api.get<Record<string, string>>('/admin/config'),
|
||||
api.get<ExportJob[]>('/admin/export/jobs')
|
||||
]);
|
||||
configDraft = { ...config };
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
setTimeout(() => (toast = null), 3000);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving = true;
|
||||
try {
|
||||
// Only send changed values
|
||||
const changes: Record<string, string> = {};
|
||||
for (const key of Object.keys(configDraft)) {
|
||||
if (configDraft[key] !== config[key]) {
|
||||
changes[key] = configDraft[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
showToast('Keine Änderungen.');
|
||||
return;
|
||||
}
|
||||
await api.patch('/admin/config', changes);
|
||||
config = { ...configDraft };
|
||||
showToast('Konfiguration gespeichert.');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function diskPct(stats: StatsDto): number {
|
||||
if (stats.disk_total_bytes === 0) return 0;
|
||||
return Math.round((stats.disk_used_bytes / stats.disk_total_bytes) * 100);
|
||||
}
|
||||
|
||||
function jobLabel(type: string): string {
|
||||
return type === 'zip' ? 'ZIP-Archiv' : 'HTML-Viewer';
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'done': return 'bg-green-100 text-green-700';
|
||||
case 'running': return 'bg-blue-100 text-blue-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'Ausstehend';
|
||||
case 'running': return 'Läuft';
|
||||
case 'done': return 'Fertig';
|
||||
case 'failed': return 'Fehlgeschlagen';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Toast -->
|
||||
{#if toast}
|
||||
<div class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/host" class="text-sm text-blue-600 hover:underline">Host-Dashboard</a>
|
||||
<a href="/feed" class="text-sm text-gray-500 hover:text-gray-700">Galerie</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-4 p-4">
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400">Laden…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||
{:else}
|
||||
<!-- Stats -->
|
||||
{#if stats}
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-4 font-semibold text-gray-900">Statistiken</h2>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.user_count}</p>
|
||||
<p class="text-xs text-gray-500">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.upload_count}</p>
|
||||
<p class="text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.comment_count}</p>
|
||||
<p class="text-xs text-gray-500">Kommentare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk usage -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Speicher</span>
|
||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)} ({diskPct(stats)} %)</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
||||
style="width: {diskPct(stats)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-4 font-semibold text-gray-900">Konfiguration</h2>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(CONFIG_LABELS) as [key, label]}
|
||||
<div class="flex items-center gap-3">
|
||||
<label for={key} class="w-56 shrink-0 text-sm text-gray-700">{label}</label>
|
||||
<input
|
||||
id={key}
|
||||
type="number"
|
||||
step="any"
|
||||
bind:value={configDraft[key]}
|
||||
class="w-32 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
onclick={saveConfig}
|
||||
disabled={saving}
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Export jobs -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900">Export-Jobs</h2>
|
||||
<button onclick={reload} class="text-xs text-blue-600 hover:underline">Aktualisieren</button>
|
||||
</div>
|
||||
{#if exportJobs.length === 0}
|
||||
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each exportJobs as job}
|
||||
<div class="rounded-lg border border-gray-100 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
||||
{statusLabel(job.status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if job.status === 'running'}
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>Fortschritt</span>
|
||||
<span>{job.progress_pct} %</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all"
|
||||
style="width: {job.progress_pct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user