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:
+103
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user