feat: Phase 9 — Dashboard web embarqué

- Frontend vanilla JS (18.9KB) : dark theme, gallery grid, search, stats, WebSocket
- rust-embed : frontend servi depuis le binaire (zéro dépendance externe)
- API REST fonctionnelle : /api/health, /api/images, /api/search, /api/images/:id, /api/stats
- WebSocket hub (dashmap + broadcast) : notifications temps réel
- ImagoClient: méthode get_raw() pour proxy thumbnails, Serialize sur ImageInfo
- MIME guess pour servir CSS/JS/images avec bons headers
- Fallback SPA : routes inconnues → index.html
- Dépendances : tokio-stream, futures-util, dashmap, chrono
- Testé : health OK, stats JSON, dashboard 18.9KB HTML, WS upgrade 400 (HTTP)

0 errors, 0 warnings (cargo check + build --release)
This commit is contained in:
2026-06-22 17:05:49 -04:00
parent b680c0fff7
commit e92da02067
8 changed files with 994 additions and 42 deletions
+103 -5
View File
@@ -1,18 +1,48 @@
//! Serveur web — Dashboard embarqué (axum)
//!
//! Phase 9: API REST + WebSocket + frontend Svelte embarqué via rust-embed
//! Phase 9: API REST + WebSocket + frontend embarqué via rust-embed
pub mod routes;
pub mod ws;
use axum::Router;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::imago_client::ImagoClient;
use crate::config::Config;
/// Démarre le serveur web
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
/// Frontend embarqué dans le binaire
#[derive(RustEmbed)]
#[folder = "src/frontend/"]
struct FrontendAssets;
/// Démarre le serveur web avec le dashboard complet
pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow::Result<()> {
// Initialiser le client Imago (ne bloque pas si indisponible)
let imago = Arc::new(
ImagoClient::new(config.imago.url.clone(), config.imago.api_key.clone())
.unwrap_or_else(|e| {
tracing::warn!("Client Imago non initialisé : {} — dashboard en mode dégradé", e);
// Client fallback avec URL vide
ImagoClient::new(String::new(), String::new()).unwrap()
})
);
// Hub WebSocket
let ws_hub = ws::WsHub::new();
// État partagé
let state = routes::AppState {
imago,
ws_hub: ws_hub.clone(),
};
// Router principal
let app = Router::new()
.merge(routes::api_routes())
.merge(routes::static_routes());
.merge(routes::api_routes(state))
.route("/ws", axum::routing::get(ws::ws_handler).with_state(ws_hub))
.fallback(serve_static);
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
tracing::info!("Dashboard web démarré sur http://{}", addr);
@@ -22,3 +52,71 @@ pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
Ok(())
}
/// Sert le frontend statique embarqué via rust-embed
async fn serve_static(
req: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
let path = req.uri().path();
let path = path.trim_start_matches('/');
// Par défaut, servir index.html
let asset_path = if path.is_empty() { "index.html" } else { path };
match FrontendAssets::get(asset_path) {
Some(content) => {
let mime = mime_guess(asset_path);
axum::http::Response::builder()
.header("Content-Type", mime)
.header("Cache-Control", "public, max-age=3600")
.body(axum::body::Body::from(content.data.to_vec()))
.unwrap()
}
None => {
// Fallback SPA: toutes les routes inconnues renvoient index.html
if let Some(index) = FrontendAssets::get("index.html") {
axum::http::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(axum::body::Body::from(index.data.to_vec()))
.unwrap()
} else {
axum::http::Response::builder()
.status(axum::http::StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain")
.body(axum::body::Body::from("404 — Page non trouvée"))
.unwrap()
}
}
}
}
/// Devine le type MIME à partir de l'extension
fn mime_guess(path: &str) -> &'static str {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => "application/octet-stream",
}
}
// ─── Legacy: compatibilité avec l'ancienne signature (Phase 7) ─────────────
/// Version simplifiée sans config (utilise les défauts)
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
let config = Config::default();
serve_with_config(host, port, &config).await
}