From ff3e7ebe96bf92c7f79b796e6555d873aad614d0 Mon Sep 17 00:00:00 2001 From: oussi Date: Tue, 7 Apr 2026 11:38:03 +0200 Subject: [PATCH] feat: routes modules (auth, dashboard, settings, alerts, users) --- src/routes/alerts.rs | 54 +++++- src/routes/auth.rs | 68 ++++++- src/routes/dashboard.rs | 64 ++++++- src/routes/mod.rs | 124 +++++++++++++ src/routes/settings.rs | 399 +++++++++++++++++++++++++++++++++++++++- src/routes/users.rs | 59 +++++- 6 files changed, 763 insertions(+), 5 deletions(-) diff --git a/src/routes/alerts.rs b/src/routes/alerts.rs index 9d3b2d1..df633fe 100644 --- a/src/routes/alerts.rs +++ b/src/routes/alerts.rs @@ -1 +1,53 @@ -// Alerts routes — Task 10 +use axum::{ + extract::State, + response::{IntoResponse, Redirect}, +}; +use tower_sessions::Session; + +use crate::routes::{flash, get_and_clear_flash, render_html, AppState, AuthUser}; + +pub async fn alerts_get( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let flash_messages = get_and_clear_flash(&session).await; + let raw_alerts = { + let cm = state.config_manager.lock().await; + cm.load_alerts() + }; + + // Formater les timestamps (ISO → "YYYY-MM-DD HH:MM:SS") + let alerts: Vec = raw_alerts + .iter() + .map(|a| { + let mut v = serde_json::to_value(a).unwrap(); + if let Some(ts) = v.get("timestamp").and_then(|t| t.as_str()) { + let formatted = ts.chars().take(19).collect::().replace('T', " "); + v["timestamp_display"] = serde_json::Value::String(formatted); + } + v + }) + .collect(); + + let mut ctx = tera::Context::new(); + ctx.insert("flash_messages", &flash_messages); + ctx.insert("is_authenticated", &true); + ctx.insert("active_page", "alerts"); + ctx.insert("alerts", &alerts); + + render_html(&state.tera, "alerts.html", ctx) +} + +pub async fn clear_alerts( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + { + let cm = state.config_manager.lock().await; + cm.clear_alerts(); + } + flash(&session, "success", "Historique des alertes effacé.").await; + Redirect::to("/alerts") +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 6694550..e60c7cb 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1 +1,67 @@ -// Auth routes — Task 7 +use axum::{ + extract::{Form, State}, + response::{IntoResponse, Redirect}, +}; +use serde::Deserialize; +use tower_sessions::Session; + +use crate::routes::{flash, get_and_clear_flash, render_html, AppState, SESSION_USER_KEY}; + +#[derive(Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +pub async fn login_get( + session: Session, + State(state): State, +) -> impl IntoResponse { + // Si déjà connecté, rediriger vers dashboard + if session + .get::(SESSION_USER_KEY) + .await + .unwrap_or_default() + .is_some() + { + return Redirect::to("/").into_response(); + } + let flash_messages = get_and_clear_flash(&session).await; + let mut ctx = tera::Context::new(); + ctx.insert("flash_messages", &flash_messages); + ctx.insert("is_authenticated", &false); + render_html(&state.tera, "login.html", ctx).into_response() +} + +pub async fn login_post( + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + let (admin_username, admin_hash) = { + let cm = state.config_manager.lock().await; + ( + cm.config.admin.username.clone(), + cm.config.admin.password_hash.clone(), + ) + }; + + let username = form.username.trim().to_string(); + let password = form.password.clone(); + + let valid = username == admin_username + && bcrypt::verify(&password, &admin_hash).unwrap_or(false); + + if valid { + session.insert(SESSION_USER_KEY, username).await.ok(); + return Redirect::to("/").into_response(); + } + + flash(&session, "danger", "Identifiants incorrects.").await; + Redirect::to("/login").into_response() +} + +pub async fn logout(session: Session) -> impl IntoResponse { + session.flush().await.ok(); + Redirect::to("/login") +} diff --git a/src/routes/dashboard.rs b/src/routes/dashboard.rs index 45e43ec..c4f0b34 100644 --- a/src/routes/dashboard.rs +++ b/src/routes/dashboard.rs @@ -1 +1,63 @@ -// Dashboard routes — Task 8 +use axum::{ + extract::State, + response::{IntoResponse, Json, Redirect}, +}; +use tower_sessions::Session; + +use crate::routes::{flash, get_and_clear_flash, render_html, AppState, AuthUser}; + +pub async fn dashboard( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let flash_messages = get_and_clear_flash(&session).await; + let metrics = state.monitor.metrics.read().unwrap().clone(); + + let (is_default_pw, _admin_username) = { + let cm = state.config_manager.lock().await; + let hash = cm.config.admin.password_hash.clone(); + let username = cm.config.admin.username.clone(); + ( + bcrypt::verify("admin", &hash).unwrap_or(false), + username, + ) + }; + + let mut ctx = tera::Context::new(); + ctx.insert("flash_messages", &flash_messages); + ctx.insert("is_authenticated", &true); + ctx.insert("active_page", "dashboard"); + ctx.insert("metrics", &metrics); + ctx.insert("default_pw", &is_default_pw); + + render_html(&state.tera, "dashboard.html", ctx) +} + +pub async fn api_metrics( + _auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let metrics = state.monitor.metrics.read().unwrap().clone(); + Json(metrics) +} + +pub async fn toggle_monitoring( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let is_running = state + .monitor + .running + .load(std::sync::atomic::Ordering::Relaxed); + if is_running { + state.monitor.stop(); + flash(&session, "warning", "Monitoring arrêté.").await; + } else { + let m = state.monitor.clone(); + m.start().await; + flash(&session, "success", "Monitoring démarré.").await; + } + Redirect::to("/") +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index aad9a44..cfa4833 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,3 +3,127 @@ pub mod dashboard; pub mod settings; pub mod alerts; pub mod users; + +use axum::{ + async_trait, + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponse, Redirect, Response}, +}; +use std::sync::{Arc, RwLock}; +use tera::Tera; +use tokio::sync::Mutex as AsyncMutex; +use tower_sessions::Session; + +use crate::alerter::Alerter; +use crate::config::ConfigManager; +use crate::monitor::SystemMonitor; +use crate::user_monitor::UserMonitor; + +pub const SESSION_USER_KEY: &str = "username"; +pub const SESSION_FLASH_KEY: &str = "flash_messages"; + +#[derive(Clone)] +pub struct AppState { + pub config_manager: Arc>, + pub monitor: Arc, + pub alerter: Arc, + pub user_monitor: Arc, + pub tera: Arc, +} + +impl AppState { + pub fn new( + config_manager: Arc>, + monitor: Arc, + alerter: Arc, + user_monitor: Arc, + ) -> Self { + let tera = build_tera(); + AppState { + config_manager, + monitor, + alerter, + user_monitor, + tera: Arc::new(tera), + } + } +} + +fn build_tera() -> Tera { + let mut tera = Tera::default(); + tera.add_raw_templates(vec![ + ("base.html", include_str!("../../templates/base.html")), + ("login.html", include_str!("../../templates/login.html")), + ( + "dashboard.html", + include_str!("../../templates/dashboard.html"), + ), + ( + "settings.html", + include_str!("../../templates/settings.html"), + ), + ("alerts.html", include_str!("../../templates/alerts.html")), + ("users.html", include_str!("../../templates/users.html")), + ]) + .expect("Erreur chargement templates Tera"); + tera +} + +pub async fn flash(session: &Session, category: &str, message: &str) { + let mut messages: Vec<(String, String)> = session + .get::>(SESSION_FLASH_KEY) + .await + .unwrap_or_default() + .unwrap_or_default(); + messages.push((category.to_string(), message.to_string())); + session.insert(SESSION_FLASH_KEY, messages).await.ok(); +} + +pub async fn get_and_clear_flash(session: &Session) -> Vec<(String, String)> { + let messages: Vec<(String, String)> = session + .get::>(SESSION_FLASH_KEY) + .await + .unwrap_or_default() + .unwrap_or_default(); + session + .remove::>(SESSION_FLASH_KEY) + .await + .ok(); + messages +} + +pub struct AuthUser(pub String); + +#[async_trait] +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|e| e.into_response())?; + match session + .get::(SESSION_USER_KEY) + .await + .unwrap_or_default() + { + Some(u) => Ok(AuthUser(u)), + None => Err(Redirect::to("/login").into_response()), + } + } +} + +pub fn render_html( + tera: &Tera, + template: &str, + ctx: tera::Context, +) -> axum::response::Html { + match tera.render(template, &ctx) { + Ok(html) => axum::response::Html(html), + Err(e) => axum::response::Html(format!("
Erreur template: {}
", e)), + } +} diff --git a/src/routes/settings.rs b/src/routes/settings.rs index f843146..1329aa5 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1 +1,398 @@ -// Settings routes — Task 9 +use axum::{ + extract::{Form, State}, + response::{IntoResponse, Redirect}, +}; +use serde::Deserialize; +use tower_sessions::Session; + +use crate::routes::{flash, get_and_clear_flash, render_html, AppState, AuthUser}; + +pub async fn settings_get( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let flash_messages = get_and_clear_flash(&session).await; + let (config, is_default_pw) = { + let cm = state.config_manager.lock().await; + let pw_default = + bcrypt::verify("admin", &cm.config.admin.password_hash).unwrap_or(false); + (cm.config.clone(), pw_default) + }; + + let smtp_password_masked = if config.smtp.password.is_empty() { + "".to_string() + } else { + "********".to_string() + }; + + let mut ctx = tera::Context::new(); + ctx.insert("flash_messages", &flash_messages); + ctx.insert("is_authenticated", &true); + ctx.insert("active_page", "settings"); + ctx.insert("config", &config); + ctx.insert("smtp", &config.smtp); + ctx.insert("smtp_password_masked", &smtp_password_masked); + ctx.insert("default_pw", &is_default_pw); + + render_html(&state.tera, "settings.html", ctx) +} + +#[derive(Deserialize)] +pub struct ThresholdsForm { + pub cpu_percent: u32, + pub ram_percent: u32, + pub disk_percent: u32, +} + +pub async fn update_thresholds( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if !(1..=100).contains(&form.cpu_percent) + || !(1..=100).contains(&form.ram_percent) + || !(1..=100).contains(&form.disk_percent) + { + flash( + &session, + "danger", + "Les seuils doivent être entre 1 et 100.", + ) + .await; + return Redirect::to("/settings"); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.thresholds.cpu_percent = form.cpu_percent as f64; + cm.config.thresholds.ram_percent = form.ram_percent as f64; + cm.config.thresholds.disk_percent = form.disk_percent as f64; + cm.save(); + } + flash(&session, "success", "Seuils mis à jour.").await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct MonitoringForm { + pub check_interval_minutes: u64, + pub alert_cooldown_minutes: u64, +} + +pub async fn update_monitoring( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if form.check_interval_minutes < 1 { + flash( + &session, + "danger", + "L'intervalle doit être d'au moins 1 minute.", + ) + .await; + return Redirect::to("/settings"); + } + if form.alert_cooldown_minutes < 1 { + flash( + &session, + "danger", + "Le cooldown doit être d'au moins 1 minute.", + ) + .await; + return Redirect::to("/settings"); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.check_interval_minutes = form.check_interval_minutes; + cm.config.alert_cooldown_minutes = form.alert_cooldown_minutes; + cm.save(); + } + flash( + &session, + "success", + "Paramètres de monitoring mis à jour.", + ) + .await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct SmtpForm { + pub smtp_server: String, + pub smtp_port: u16, + pub smtp_tls: Option, + pub smtp_username: String, + pub smtp_password: Option, + pub smtp_from: String, + pub smtp_to: String, +} + +pub async fn update_smtp( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + let to_emails: Vec = form + .smtp_to + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + { + let mut cm = state.config_manager.lock().await; + let old_password = cm.config.smtp.password.clone(); + cm.config.smtp.server = form.smtp_server.trim().to_string(); + cm.config.smtp.port = form.smtp_port; + cm.config.smtp.use_tls = form.smtp_tls.is_some(); + cm.config.smtp.username = form.smtp_username.trim().to_string(); + cm.config.smtp.from_email = form.smtp_from.trim().to_string(); + cm.config.smtp.to_emails = to_emails; + cm.config.smtp.password = match form.smtp_password { + Some(pw) if !pw.is_empty() => pw, + _ => old_password, + }; + cm.save(); + } + flash(&session, "success", "Configuration SMTP mise à jour.").await; + Redirect::to("/settings") +} + +pub async fn test_smtp( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let smtp = { + let cm = state.config_manager.lock().await; + cm.config.smtp.clone() + }; + let (ok, msg) = state.alerter.send_test(&smtp).await; + if ok { + flash(&session, "success", &format!("Test réussi : {}", msg)).await; + } else { + flash(&session, "danger", &format!("Test échoué : {}", msg)).await; + } + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct ProcessesForm { + #[serde(rename = "proc_name[]")] + pub proc_name: Option>, + #[serde(rename = "proc_pattern[]")] + pub proc_pattern: Option>, + #[serde(rename = "proc_mem_threshold[]")] + pub proc_mem_threshold: Option>, + #[serde(rename = "proc_enabled[]")] + pub proc_enabled: Option>, + #[serde(rename = "proc_alert_down[]")] + pub proc_alert_down: Option>, +} + +pub async fn update_processes( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + use crate::config::ProcessConfig; + let names = form.proc_name.unwrap_or_default(); + let patterns = form.proc_pattern.unwrap_or_default(); + let mem_thresholds = form.proc_mem_threshold.unwrap_or_default(); + let enableds = form.proc_enabled.unwrap_or_default(); + let alert_downs = form.proc_alert_down.unwrap_or_default(); + + let mut processes = Vec::new(); + for (i, name) in names.iter().enumerate() { + let name = name.trim().to_string(); + if name.is_empty() { + continue; + } + processes.push(ProcessConfig { + name, + pattern: patterns + .get(i) + .map(|s| s.trim().to_lowercase()) + .unwrap_or_default(), + memory_threshold_mb: mem_thresholds + .get(i) + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), + enabled: enableds.contains(&i.to_string()), + alert_on_down: alert_downs.contains(&i.to_string()), + }); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.processes = processes; + cm.save(); + } + flash(&session, "success", "Processus surveillés mis à jour.").await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct PasswordForm { + pub current_password: String, + pub new_password: String, + pub confirm_password: String, +} + +pub async fn update_password( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + let hash = { + let cm = state.config_manager.lock().await; + cm.config.admin.password_hash.clone() + }; + if !bcrypt::verify(&form.current_password, &hash).unwrap_or(false) { + flash(&session, "danger", "Mot de passe actuel incorrect.").await; + return Redirect::to("/settings"); + } + if form.new_password.len() < 8 { + flash( + &session, + "danger", + "Le nouveau mot de passe doit faire au moins 8 caractères.", + ) + .await; + return Redirect::to("/settings"); + } + if form.new_password != form.confirm_password { + flash( + &session, + "danger", + "Les mots de passe ne correspondent pas.", + ) + .await; + return Redirect::to("/settings"); + } + let new_hash = match bcrypt::hash(&form.new_password, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(_) => { + flash( + &session, + "danger", + "Erreur lors du hachage du mot de passe.", + ) + .await; + return Redirect::to("/settings"); + } + }; + { + let mut cm = state.config_manager.lock().await; + cm.config.admin.password_hash = new_hash; + cm.save(); + } + flash(&session, "success", "Mot de passe mis à jour.").await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct PortForm { + pub port: u16, +} + +pub async fn update_port( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if !(1024..=65535).contains(&form.port) { + flash( + &session, + "danger", + "Le port doit être entre 1024 et 65535.", + ) + .await; + return Redirect::to("/settings"); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.port = form.port; + cm.save(); + } + flash( + &session, + "warning", + &format!( + "Port mis à jour à {}. Redémarrez l'application pour appliquer.", + form.port + ), + ) + .await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct AmadeaLogPathForm { + pub amadea_log_path: String, +} + +pub async fn update_amadea_log_path( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + let path = form.amadea_log_path.trim().to_string(); + if path.is_empty() { + flash(&session, "danger", "Le chemin ne peut pas être vide.").await; + return Redirect::to("/settings"); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.amadea_log_path = path; + cm.save(); + } + flash(&session, "success", "Chemin des logs Amadea mis à jour.").await; + Redirect::to("/settings") +} + +#[derive(Deserialize)] +pub struct UserThresholdsForm { + pub active_minutes: u64, + pub inactive_minutes: u64, +} + +pub async fn update_user_thresholds( + _auth: AuthUser, + session: Session, + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if form.active_minutes < 1 || form.inactive_minutes < 1 { + flash( + &session, + "danger", + "Les seuils doivent être d'au moins 1 minute.", + ) + .await; + return Redirect::to("/settings"); + } + if form.active_minutes >= form.inactive_minutes { + flash( + &session, + "danger", + "Le seuil 'actif' doit être inférieur au seuil 'inactif'.", + ) + .await; + return Redirect::to("/settings"); + } + { + let mut cm = state.config_manager.lock().await; + cm.config.user_status_thresholds.active_minutes = form.active_minutes; + cm.config.user_status_thresholds.inactive_minutes = form.inactive_minutes; + cm.save(); + } + flash(&session, "success", "Seuils utilisateurs mis à jour.").await; + Redirect::to("/settings") +} diff --git a/src/routes/users.rs b/src/routes/users.rs index 1f7b19c..d03c80f 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1 +1,58 @@ -// Users routes — Task 11 +use axum::{ + extract::State, + response::{IntoResponse, Json}, +}; +use serde_json::json; +use tower_sessions::Session; + +use crate::routes::{get_and_clear_flash, render_html, AppState, AuthUser}; + +pub async fn users_get( + _auth: AuthUser, + session: Session, + State(state): State, +) -> impl IntoResponse { + let flash_messages = get_and_clear_flash(&session).await; + let mut ctx = tera::Context::new(); + ctx.insert("flash_messages", &flash_messages); + ctx.insert("is_authenticated", &true); + ctx.insert("active_page", "users"); + render_html(&state.tera, "users.html", ctx) +} + +pub async fn api_users( + _auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let data = state.user_monitor.data.lock().unwrap().clone(); + if let Some(err) = &data.error { + return Json(json!({ "error": err })); + } + if data.no_files { + return Json(json!({ "no_files": true })); + } + let users: Vec = data + .users + .iter() + .map(|u| { + json!({ + "login": u.login, + "status": u.status, + "last_action_time": u.last_action_time.format("%H:%M:%S").to_string(), + "last_action_label": u.last_action_label, + "action_count_24h": u.action_count_24h, + "connected_since": u.connected_since.map(|t| t.format("%H:%M").to_string()), + "explicit_logout": u.explicit_logout, + }) + }) + .collect(); + Json(json!({ "users": users, "hourly": data.hourly })) +} + +pub async fn api_users_weekly( + _auth: AuthUser, + State(state): State, +) -> impl IntoResponse { + let weekly = state.user_monitor.get_weekly_activity().await; + Json(json!({ "weekly": weekly })) +}