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:
MechaCat02
2026-04-02 20:45:37 +02:00
parent 71a2987a3e
commit 32c16da3e2
4 changed files with 479 additions and 1 deletions

View File

@@ -0,0 +1,202 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StatsDto {
pub user_count: i64,
pub upload_count: i64,
pub comment_count: i64,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_free_bytes: u64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct ExportJobDto {
pub id: uuid::Uuid,
pub r#type: String,
pub status: String,
pub progress_pct: i16,
pub error_message: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_stats(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<StatsDto>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let (user_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (comment_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM comment c
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
// Disk usage via sysinfo
let mut sys = System::new();
sys.refresh_all();
let media_path = state.config.media_path.to_string_lossy().to_string();
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or_else(|| {
// Fall back to the root disk
sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| d.mount_point().to_string_lossy() == "/")
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or((0, 0))
});
let disk_used = disk_total.saturating_sub(disk_free);
Ok(Json(StatsDto {
user_count,
upload_count,
comment_count,
disk_total_bytes: disk_total,
disk_used_bytes: disk_used,
disk_free_bytes: disk_free,
}))
}
pub async fn get_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
.fetch_all(&state.pool)
.await?;
Ok(Json(rows.into_iter().collect()))
}
#[derive(Deserialize)]
pub struct PatchConfigRequest(pub HashMap<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
const ALLOWED_KEYS: &[&str] = &[
"max_image_size_mb",
"max_video_size_mb",
"upload_rate_per_hour",
"feed_rate_per_min",
"export_rate_per_day",
"quota_tolerance",
"estimated_guest_count",
"compression_concurrency",
];
for (key, value) in &body {
if !ALLOWED_KEYS.contains(&key.as_str()) {
return Err(AppError::BadRequest(format!("Unbekannter Konfigurationsschlüssel: {key}")));
}
// Validate numeric values
if value.parse::<f64>().is_err() {
return Err(AppError::BadRequest(format!("Ungültiger Wert für {key}: muss eine Zahl sein.")));
}
sqlx::query(
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
)
.bind(key)
.bind(value)
.execute(&state.pool)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_export_jobs(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<Vec<ExportJobDto>>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let jobs = sqlx::query_as::<_, ExportJobDto>(
"SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at
FROM export_job
WHERE event_id = $1
ORDER BY created_at DESC",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
Ok(Json(jobs))
}
/// Also expose export status to all authenticated users (guests need it for the export page)
pub async fn export_status(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let released = event.export_released_at.is_some();
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
let job_status = |type_name: &str| {
jobs.iter()
.find(|(t, _, _)| t == type_name)
.map(|(_, status, pct)| {
serde_json::json!({ "status": status, "progress_pct": pct })
})
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
};
Ok(Json(serde_json::json!({
"released": released,
"zip": job_status("zip"),
"html": job_status("html"),
})))
}

View File

@@ -1,3 +1,4 @@
pub mod admin;
pub mod feed; pub mod feed;
pub mod host; pub mod host;
pub mod social; pub mod social;

View File

@@ -69,7 +69,16 @@ async fn main() -> Result<()> {
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user)) .route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role)) .route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload)) .route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)); .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export status (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
// Admin Dashboard
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
.route(
"/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
)
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
// Serve media files from disk // Serve media files from disk
let media_service = ServeDir::new(&config.media_path); let media_service = ServeDir::new(&config.media_path);

View 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 (01)',
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>