//! Serveur web — Dashboard embarqué (axum) //! //! 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 std::collections::HashSet; use tokio::sync::Mutex; use crate::imago_client::ImagoClient; use crate::config::Config; /// 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(); // Base de données de dédoublonnage (partagée avec le watcher) let data_dir = if cfg!(target_os = "windows") { let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into()); std::path::PathBuf::from(appdata) } else { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); std::path::PathBuf::from(home).join(".config") }; let dedup_db_path = data_dir.join("imagod-web").join("imagod.db"); // État partagé let state = routes::AppState { imago, base_url: config.imago.url.clone(), api_key: config.imago.api_key.clone(), ws_hub: ws_hub.clone(), dedup_db_path, in_flight: Mutex::new(HashSet::new()), }; // Router principal let app = Router::new() .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); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; Ok(()) } /// Sert le frontend statique embarqué via rust-embed async fn serve_static( req: axum::http::Request, ) -> 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".to_string() } else { path.to_string() }; // Essayer avec le chemin exact if let Some(content) = FrontendAssets::get(&asset_path) { let mime = mime_guess(&asset_path); return 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(); } // Essayer avec .html si pas d'extension if !asset_path.contains('.') { let html_path = format!("{}.html", asset_path); if let Some(content) = FrontendAssets::get(&html_path) { return axum::http::Response::builder() .header("Content-Type", "text/html; charset=utf-8") .body(axum::body::Body::from(content.data.to_vec())) .unwrap(); } } // 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 }