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:
202
backend/src/handlers/admin.rs
Normal file
202
backend/src/handlers/admin.rs
Normal 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"),
|
||||
})))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod social;
|
||||
|
||||
Reference in New Issue
Block a user