feat: routes modules (auth, dashboard, settings, alerts, users)

This commit is contained in:
oussi
2026-04-07 11:38:03 +02:00
parent a720e61f58
commit ff3e7ebe96
6 changed files with 763 additions and 5 deletions

View File

@@ -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<AppState>,
) -> 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<serde_json::Value> = 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::<String>().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<AppState>,
) -> impl IntoResponse {
{
let cm = state.config_manager.lock().await;
cm.clear_alerts();
}
flash(&session, "success", "Historique des alertes effacé.").await;
Redirect::to("/alerts")
}

View File

@@ -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<AppState>,
) -> impl IntoResponse {
// Si déjà connecté, rediriger vers dashboard
if session
.get::<String>(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<AppState>,
Form(form): Form<LoginForm>,
) -> 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")
}

View File

@@ -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<AppState>,
) -> 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<AppState>,
) -> impl IntoResponse {
let metrics = state.monitor.metrics.read().unwrap().clone();
Json(metrics)
}
pub async fn toggle_monitoring(
_auth: AuthUser,
session: Session,
State(state): State<AppState>,
) -> 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("/")
}

View File

@@ -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<AsyncMutex<ConfigManager>>,
pub monitor: Arc<SystemMonitor>,
pub alerter: Arc<Alerter>,
pub user_monitor: Arc<UserMonitor>,
pub tera: Arc<Tera>,
}
impl AppState {
pub fn new(
config_manager: Arc<AsyncMutex<ConfigManager>>,
monitor: Arc<SystemMonitor>,
alerter: Arc<Alerter>,
user_monitor: Arc<UserMonitor>,
) -> 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::<Vec<(String, String)>>(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::<Vec<(String, String)>>(SESSION_FLASH_KEY)
.await
.unwrap_or_default()
.unwrap_or_default();
session
.remove::<Vec<(String, String)>>(SESSION_FLASH_KEY)
.await
.ok();
messages
}
pub struct AuthUser(pub String);
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|e| e.into_response())?;
match session
.get::<String>(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<String> {
match tera.render(template, &ctx) {
Ok(html) => axum::response::Html(html),
Err(e) => axum::response::Html(format!("<pre>Erreur template: {}</pre>", e)),
}
}

View File

@@ -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<AppState>,
) -> 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<AppState>,
Form(form): Form<ThresholdsForm>,
) -> 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<AppState>,
Form(form): Form<MonitoringForm>,
) -> 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<String>,
pub smtp_username: String,
pub smtp_password: Option<String>,
pub smtp_from: String,
pub smtp_to: String,
}
pub async fn update_smtp(
_auth: AuthUser,
session: Session,
State(state): State<AppState>,
Form(form): Form<SmtpForm>,
) -> impl IntoResponse {
let to_emails: Vec<String> = 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<AppState>,
) -> 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<Vec<String>>,
#[serde(rename = "proc_pattern[]")]
pub proc_pattern: Option<Vec<String>>,
#[serde(rename = "proc_mem_threshold[]")]
pub proc_mem_threshold: Option<Vec<String>>,
#[serde(rename = "proc_enabled[]")]
pub proc_enabled: Option<Vec<String>>,
#[serde(rename = "proc_alert_down[]")]
pub proc_alert_down: Option<Vec<String>>,
}
pub async fn update_processes(
_auth: AuthUser,
session: Session,
State(state): State<AppState>,
Form(form): Form<ProcessesForm>,
) -> 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<AppState>,
Form(form): Form<PasswordForm>,
) -> 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<AppState>,
Form(form): Form<PortForm>,
) -> 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<AppState>,
Form(form): Form<AmadeaLogPathForm>,
) -> 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<AppState>,
Form(form): Form<UserThresholdsForm>,
) -> 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")
}

View File

@@ -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<AppState>,
) -> 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<AppState>,
) -> 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<serde_json::Value> = 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<AppState>,
) -> impl IntoResponse {
let weekly = state.user_monitor.get_weekly_activity().await;
Json(json!({ "weekly": weekly }))
}